Skip to content

Commit c8d0633

Browse files
ci,scripts: pin the conformance catalog tooling, stop ignoring uv lockfiles, record the changelog
The conformance suite stops xfailing the inbound DPoP-Nonce case now that the catalog carries it, and the catalog-fetch tooling is aligned with the pinned ref so a local run matches CI rather than the moving default branch. `backport-fixes.sh` accepts a tag as `--from`: `release.yml` deletes `release/vX.Y.Z` once the tag is pushed, so afterwards the tag is the only ref naming those commits — which is exactly what the release summary tells the operator to pass. `--from` and `--to` are validated as ref names before reaching a fetch refspec, since `git ls-remote` matches its arguments as globs. `uv.lock` is ignored: nothing tracks or consumes these files — no workflow installs with uv — so they are local resolution artifacts. Three of them were swept into a merge commit as untracked files once and accounted for 94% of that diff. Tracking them for reproducible installs is a real decision and belongs in its own PR, alongside the CI change that would make them load-bearing.
1 parent 1e86bfa commit c8d0633

9 files changed

Lines changed: 888 additions & 73 deletions

File tree

‎.github/workflows/security.yml‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,6 @@ jobs:
7474
# latest released version, so `pip install --upgrade pip` can't pull
7575
# a patched build. Drop this ignore once pip >= 26.1 is on PyPI.
7676
# See https://github.com/pypa/pip/pull/13870.
77-
#
78-
# PYSEC-2026-3483: affects mcp <= 1.27.2 (fixed in 1.28.1). The
79-
# authplane-mcp adapter pins mcp <1.28.0 because 1.28 renamed the
80-
# elicitation field elicitationId -> elicitation_id (snake_case),
81-
# which breaks url_elicitation.py's ElicitRequestURLParams wire
82-
# handling (every consent-driven exchange would raise a pydantic
83-
# ValidationError). Accepted risk until the adapter is migrated to
84-
# the 1.28 field name and the floor is raised to 1.28.1; drop this
85-
# ignore then.
8677
run: >-
8778
pip-audit --skip-editable --progress-spinner off
8879
--ignore-vuln CVE-2026-3219
89-
--ignore-vuln PYSEC-2026-3483

‎.github/workflows/workflows-lint.yml‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
1-
name: Lint workflows
1+
name: Release tooling
22

33
# Catches workflow YAML / shell-in-`run:` regressions at PR time so a
44
# typo can't reach a release tag and surface only when a publish run
5-
# fails. Scoped to changes under `.github/workflows/**` to keep CI
6-
# overhead off unrelated PRs.
5+
# fails. The shell scripts under scripts/ are in the same category — a
6+
# break in them surfaces only when someone reaches for them after a
7+
# release, which is the worst moment to discover it — so they are linted
8+
# and tested here too. Scoped to those two paths to keep CI overhead off
9+
# unrelated PRs.
10+
#
11+
# The scripts trigger is `scripts/**`, not `scripts/*.sh`: a single-level
12+
# glob would leave a future scripts/lib/*.sh both untriggered here and
13+
# unlinted below, in each case silently.
714

815
on:
916
pull_request:
1017
paths:
1118
- ".github/workflows/**"
19+
- "scripts/**"
1220
push:
1321
branches:
1422
- main
1523
paths:
1624
- ".github/workflows/**"
25+
- "scripts/**"
1726

1827
permissions:
1928
contents: read
@@ -56,9 +65,36 @@ jobs:
5665
echo "${ACTIONLINT_INSTALL_DIR}" >> "${GITHUB_PATH}"
5766
"${ACTIONLINT_INSTALL_DIR}/actionlint" -version
5867
59-
# `-shellcheck=shellcheck` makes the shellcheck dependency explicit
60-
# rather than relying on actionlint's implicit lookup against the
61-
# runner image's $PATH; if the Ubuntu image ever drops shellcheck the
62-
# job fails loudly instead of silently degrading.
68+
# Both steps below resolve `shellcheck` off the runner image's $PATH —
69+
# actionlint via `-shellcheck=shellcheck`, the script lint directly.
70+
# Asserting it once, up front, is what makes that dependency explicit:
71+
# naming the binary in actionlint's flag only changes which lookup
72+
# fails, and neither step announces the version it linted with. If the
73+
# Ubuntu image ever drops shellcheck, this fails first and says so,
74+
# rather than actionlint quietly degrading to no shell analysis.
75+
- name: Check shellcheck is available
76+
run: shellcheck --version
77+
6378
- name: Run actionlint
6479
run: actionlint -color -shellcheck=shellcheck
80+
81+
- name: Shellcheck the release scripts
82+
# find, not `scripts/*.sh`: the single-level glob would silently skip
83+
# a future scripts/lib/*.sh, the same blind spot the path trigger had.
84+
# An empty result is an error rather than a green no-op, so a moved or
85+
# renamed directory cannot pass as a clean lint.
86+
run: |
87+
mapfile -d '' -t sh_files < <(find scripts -type f -name '*.sh' -print0)
88+
if [[ ${#sh_files[@]} -eq 0 ]]; then
89+
echo "error: no shell scripts found under scripts/" >&2
90+
exit 1
91+
fi
92+
printf 'shellcheck: %s\n' "${sh_files[@]}"
93+
shellcheck "${sh_files[@]}"
94+
95+
# backport-fixes.sh accepts a branch or a tag as --from, and only the
96+
# branch form has a remote-tracking ref. The tag form is what the release
97+
# flow tells you to use once release.yml has deleted the branch, so it is
98+
# the form least likely to be exercised before it is needed.
99+
- name: Test backport-fixes.sh
100+
run: scripts/backport-fixes.test.sh

‎.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ wheels/
2222
.installed.cfg
2323
*.egg
2424

25+
# uv lockfiles. Not tracked on main and nothing consumes them — no workflow
26+
# installs with uv — so they are local resolution artifacts. Three of them were
27+
# swept into a merge commit as untracked files and accounted for 94% of a PR's
28+
# diff; ignoring them is what stops that recurring. Tracking them for
29+
# reproducible installs is a real decision, and it belongs in its own PR
30+
# alongside the CI change that would make them load-bearing.
31+
uv.lock
32+
2533
# Testing
2634
.pytest_cache/
2735
.coverage

‎CHANGELOG.md‎

Lines changed: 20 additions & 1 deletion
Large diffs are not rendered by default.

‎conformance-tests/README.md‎

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,35 +23,70 @@ Tests can carry optional coverage metadata to flag partial coverage or known gap
2323

2424
```python
2525
@pytest.mark.conformance(
26-
"rfc9449-dpop-proof-jwk-must-not-include-private-key-material",
26+
"<catalog-case-id>",
2727
level="partial",
2828
gaps=["expected.error_hint"],
29-
note="Python rejects the proof but does not expose a stable diagnostic.",
29+
note="<which part of the case the test does not reach>",
3030
)
3131
async def test_...(...):
3232
...
3333
```
3434

35+
The case id is a placeholder on purpose: naming a real one here would claim
36+
coverage metadata that the marker on the actual test may not carry.
37+
3538
| Parameter | Default | Description |
3639
|-----------|---------|-------------|
37-
| `level` | `"full"` | `"full"` or `"partial"` — how closely the test matches the catalog spec |
38-
| `gaps` | `[]` | List of expected catalog fields not covered by this test |
39-
| `note` | `""` | Free-text explanation (appears in both JSON and Markdown reports) |
40+
| `level` | `"full"` | `"full"` or `"partial"` — how closely the test matches the catalog spec. Always reaches `conformance-report.md`. |
41+
| `gaps` | `[]` | Catalog *field paths* the test does not reach (`use_case`, `expected.error_hint`, …). Reaches `conformance-report.md` only alongside a `note`; always reaches `conformance-report.json`. |
42+
| `note` | `""` | Free-text prose. Gates the Coverage Notes section — see below. |
43+
44+
Keep them in that order — `gaps` names the fields, `note` carries the prose —
45+
and **always write a `note` alongside `gaps`, because `note` is the gate**. In
46+
`conftest.py`'s `_build_markdown_report`, a single filter (`:216`) selects the
47+
cases with a truthy `note`, and it decides both whether the Coverage Notes
48+
section is emitted at all (`:217`) and which cases it lists (`:219`) — and the
49+
`Gaps:` line (`:224-225`) is emitted *inside* that section. So a `gaps`-only
50+
marker states no reason anywhere in `conformance-report.md` and its `gaps`
51+
survive in `conformance-report.json` alone; set a `note` and both render.
52+
53+
`level` is not gated on `note`: it reaches the markdown either way, via the
54+
Cases table's Coverage column (`:193-197`).
55+
56+
Also keep `|` out of the `note` — it is interpolated into a markdown table cell
57+
unescaped and will break the row.
58+
59+
### Partial coverage vs. not implemented
4060

41-
### Not-yet-implemented tests
61+
A test that exercises part of a case but not all of it is a `partial`: it still
62+
runs and still asserts. Prefer that over an `xfail` wherever one is honest — an
63+
`xfail` asserts nothing, so it cannot notice the day the gap closes, and it
64+
reports as a skip while the report carries the case as not-run.
4265

43-
Tests for features that don't exist yet should still be present with the marker and a `pytest.xfail(...)` body that documents what is missing:
66+
A case with nothing behind it at all should carry the marker with a
67+
`pytest.xfail(...)` body documenting what is missing:
4468

4569
```python
4670
@pytest.mark.conformance(
47-
"rfc9449-dpop-inbound-nonce-must-be-validated-when-required",
48-
note="Not implemented: the SDK has no nonce generation, DPoP-Nonce challenge emission, or challenge-retry lifecycle for resource servers.",
71+
"<catalog-case-id>",
72+
note="Not implemented: <what the SDK does not have>.",
4973
)
50-
async def test_rfc9449_dpop_inbound_nonce_must_be_validated_when_required(...):
74+
async def test_<catalog_case_id>(...):
5175
pytest.xfail("Not implemented: ...")
5276
```
5377

54-
These tests show up as `skipped` (with their `note` carried through) in both `conformance-report.json` and `conformance-report.md` — pytest classifies `xfail` outcomes as skips. Keeping the suite green for known gaps means CI never has to be ignored to merge; the gap is still visible in the report's per-case status and coverage notes.
78+
The id is a placeholder deliberately: **the suite currently has no `xfail`s**,
79+
so there is no live case to point at, and `test_catalog_alignment.py` requires
80+
every catalog id to carry a marker — so any real id named here would be one
81+
that does have a test behind it. (This section previously used
82+
`rfc9449-dpop-inbound-nonce-must-be-validated-when-required` as its worked
83+
example; that case now runs as a `partial`.)
84+
85+
`xfail` tests show up as `skipped` — with their `note` carried through — in both
86+
`conformance-report.json` and `conformance-report.md`, because pytest
87+
classifies `xfail` outcomes as skips. Keeping the suite green for known gaps
88+
means CI never has to be ignored to merge; the gap stays visible in the
89+
report's per-case status and coverage notes.
5590

5691
## Running
5792

‎conformance-tests/conftest.py‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,48 @@ async def test_rfc9068_valid_at_jwt_must_verify(...):
1010
Optional coverage metadata can be added::
1111
1212
@pytest.mark.conformance(
13-
"rfc9449-dpop-proof-jwk-must-not-include-private-key-material",
13+
"<catalog-case-id>",
1414
level="partial",
1515
gaps=["expected.error_hint"],
16-
note="Python rejects the proof but does not expose a stable diagnostic.",
16+
note="<which part of the case the test does not reach>",
1717
)
1818
async def test_...(...):
1919
...
2020
21-
Tests that are not yet implemented should use ``pytest.xfail`` — these
22-
appear as ``skipped`` in the generated report (pytest classifies ``xfail``
23-
outcomes as skips) so the suite stays green for known gaps while the gap
24-
itself remains visible in the per-case status and the ``note`` field::
21+
Case ids in this docstring are placeholders on purpose: a real id here would
22+
be claiming coverage metadata that the marker on the actual test may not
23+
carry.
2524
26-
@pytest.mark.conformance("rfc9449-dpop-inbound-nonce-must-be-validated-when-required")
25+
``gaps`` holds *catalog field paths* — the parts of the case the test does
26+
not reach. ``note`` holds the prose. Always write a ``note`` alongside
27+
``gaps``, because ``note`` is the gate: one filter selects the cases with a
28+
truthy ``note``, and it decides both whether the Coverage Notes section is
29+
emitted at all and which cases it lists — and the ``Gaps:`` line is emitted
30+
*inside* that section. So a ``gaps``-only marker states no reason anywhere
31+
in ``conformance-report.md`` and its ``gaps`` survive in
32+
``conformance-report.json`` alone; set a ``note`` and both render. ``level``
33+
is not gated on ``note`` — it reaches the markdown either way, via the Cases
34+
table's Coverage column.
35+
36+
A test that exercises part of a case but not all of it is a ``partial`` — it
37+
still runs and still asserts::
38+
39+
@pytest.mark.conformance(
40+
"<catalog-case-id>",
41+
level="partial",
42+
gaps=["use_case"],
43+
note="setup/stimulus/expected are covered; the lifecycle the use_case "
44+
"narrates is not implemented and is not exercised here.",
45+
)
2746
async def test_...(...):
28-
pytest.xfail("Not implemented: inbound nonce enforcement")
47+
...
48+
49+
A case with nothing behind it at all should use ``pytest.xfail``, which the
50+
report records as ``skipped`` (pytest classifies ``xfail`` outcomes as skips)
51+
so the suite stays green while the per-case status keeps the gap visible.
52+
Prefer a running ``partial`` where one is honest: an xfail asserts nothing,
53+
so it cannot notice the day the gap closes. The suite currently has no
54+
xfails.
2955
"""
3056

3157
import json

‎conformance-tests/test_jwt_and_dpop_conformance.py‎

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,24 +1077,76 @@ async def test_rfc9449_dpop_replay_store_must_evict_expired_entries() -> None:
10771077

10781078
@pytest.mark.conformance(
10791079
"rfc9449-dpop-inbound-nonce-must-be-validated-when-required",
1080-
note="Not implemented: the SDK has no nonce generation, DPoP-Nonce challenge emission, or challenge-retry lifecycle for resource servers.",
1080+
level="partial",
1081+
gaps=["use_case"],
1082+
note=(
1083+
"setup/stimulus/expected are covered: the verifier enforces a nonce policy "
1084+
"when one is supplied. The use_case narrative is not: the SDK issues no "
1085+
"nonce, emits no 401 + DPoP-Nonce challenge, and expected_nonce is not "
1086+
"reachable from AuthplaneResource.verify() or the MCP adapters, only from "
1087+
"verify_dpop_proof. That lifecycle is tracked separately."
1088+
),
10811089
)
10821090
async def test_rfc9449_dpop_inbound_nonce_must_be_validated_when_required(
10831091
jwks_keypair: dict[str, Any],
10841092
) -> None:
1085-
"""Full resource-server nonce challenge-retry flow:
1086-
1. Client sends proof without nonce.
1087-
2. Resource server responds 401 + DPoP-Nonce: <fresh-nonce>.
1088-
3. Client retries with the issued nonce in the proof.
1089-
4. Resource server verifies the nonce matches and accepts.
1090-
1091-
The SDK must own the nonce lifecycle: generation, DPoP-Nonce header
1092-
emission on rejection, and validation on retry. None of this is
1093-
currently implemented."""
1094-
pytest.xfail(
1095-
"Not implemented: SDK lacks nonce generation, DPoP-Nonce challenge "
1096-
"emission, and the challenge-retry lifecycle for resource servers."
1093+
"""A configured nonce policy must reject a proof that carries the wrong
1094+
nonce, and one that omits the claim entirely.
1095+
1096+
The catalog stimulus is "verify_dpop_proof with nonce policy" — setup
1097+
supplies ``expected_nonce: server-nonce-abc`` against a proof claiming
1098+
``nonce: wrong-nonce``, expecting rejection with hint "nonce". Both arms
1099+
below match ts-sdk's case for the same id. This is the RFC 9449 §9
1100+
resource-server nonce, not the §8 AS-provided one.
1101+
1102+
This was previously ``pytest.xfail``ed on the grounds that the SDK owns no
1103+
nonce lifecycle. It does not — but the catalog case does not ask for one,
1104+
and the primitive it does ask for has been present in ``verify_dpop_proof``
1105+
all along. The xfail reported as a skip, so the suite stayed green while
1106+
the report carried the case as not-run."""
1107+
provider = DPoPProvider(
1108+
DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256")
1109+
)
1110+
url = "https://api.example.com/resource"
1111+
1112+
wrong_nonce_proof = provider.build_proof("GET", url, access_token="tok", nonce="wrong-nonce")
1113+
with pytest.raises(InvalidDPoPProofError, match="nonce mismatch"):
1114+
await verify_dpop_proof(
1115+
wrong_nonce_proof,
1116+
method="GET",
1117+
url=url,
1118+
replay_store=MemoryReplayStore(),
1119+
access_token="tok",
1120+
expected_jkt=provider.key_material.thumbprint,
1121+
expected_nonce="server-nonce-abc",
1122+
)
1123+
1124+
# RFC 9449 §9: an omitted nonce claim is as much a policy violation as a
1125+
# wrong one. The catalog's requirement_summary names both.
1126+
missing_nonce_proof = provider.build_proof("GET", url, access_token="tok")
1127+
with pytest.raises(InvalidDPoPProofError, match="nonce mismatch"):
1128+
await verify_dpop_proof(
1129+
missing_nonce_proof,
1130+
method="GET",
1131+
url=url,
1132+
replay_store=MemoryReplayStore(),
1133+
access_token="tok",
1134+
expected_jkt=provider.key_material.thumbprint,
1135+
expected_nonce="server-nonce-abc",
1136+
)
1137+
1138+
# The policy must not reject the honest case: the nonce the server issued.
1139+
matching_proof = provider.build_proof("GET", url, access_token="tok", nonce="server-nonce-abc")
1140+
verified = await verify_dpop_proof(
1141+
matching_proof,
1142+
method="GET",
1143+
url=url,
1144+
replay_store=MemoryReplayStore(),
1145+
access_token="tok",
1146+
expected_jkt=provider.key_material.thumbprint,
1147+
expected_nonce="server-nonce-abc",
10971148
)
1149+
assert verified.raw["nonce"] == "server-nonce-abc"
10981150

10991151

11001152
@pytest.mark.conformance("rfc9728-well-known-path-must-derive-from-resource-uri")

0 commit comments

Comments
 (0)