Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf
583a6d92412543ea352251c88f15f2c5a39d2593
361 changes: 361 additions & 0 deletions .github/scripts/conformance-case-body-drift.sh

Large diffs are not rendered by default.

737 changes: 737 additions & 0 deletions .github/scripts/conformance-case-body-drift.test.sh

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions .github/scripts/conformance-registered-case-ids.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
#
# Print the conformance case ids THIS SDK registers, one per line.
#
# This is the repo-specific half of the case-body drift check: the id source depends on how this
# repo records registrations, so it lives here and conformance-case-body-drift.sh stays generic.
#
# Registration here is a [Conformance] attribute on a test method. There is no run-time record to
# read: nothing calls ConformanceTracker or ConformanceCaseRunner, so ConformanceRegistry is empty
# for the whole run and ConformanceReportWriter — which has no callers either — would render every
# catalog case as not_run. So the ids come from the marker scan instead, emitted by
# ConformanceMarkerScanWriter from ConformanceCatalogAlignment.ScanConformanceMarkers.
#
# That scan is reflection over the compiled attributes, not a match over the test sources. It reads
# what the runtime reads, it raises on an assembly whose types will not load rather than returning
# a short list, and — the part that matters — it is the same scan the catalog-alignment assertion
# is written against, asserted in both directions on every PR: every catalog case must carry a
# marker, and every marker must name a catalog case. A marker this extractor missed would surface
# there as an uncovered catalog case and turn the run red. There is no path by which the id list
# silently shortens.
#
# The scan carries one entry per marker OCCURRENCE, so a case claimed by more than one test appears
# more than once and this script deduplicates. `declared_by` is not filtered on: it is checked for
# presence, because an entry without it is not a case that happens not to be registered — it is the
# emitter's contract having changed under this script. Nothing keys on the test's outcome either. A
# registered case whose test failed or was skipped is still registered and still needs its body
# watched.
#
# An assembly that declares no markers emits a scan file with an empty case list. That is a
# legitimate state — the MCP adapter test assembly is in it today — so emptiness is rejected on the
# union rather than per file. The union being empty is a hard failure: an id list with nothing in
# it makes the drift check vacuously green, which is the failure it exists to prevent.
#
# Requires the alignment tests to have run under CONFORMANCE_MARKER_SCAN_DIR, so the scan on disk
# belongs to this commit.
#
# Inputs (environment):
# CONFORMANCE_MARKER_SCAN_DIR directory holding one <AssemblyName>.json scan file per test
# assembly (default: $GITHUB_WORKSPACE/conformance-marker-scan)
#
# Exit status:
# 0 ids printed on stdout
# 1 the scan is missing, unreadable, malformed, or holds no registered case

set -euo pipefail

SCAN_DIR="${CONFORMANCE_MARKER_SCAN_DIR:-${GITHUB_WORKSPACE:-.}/conformance-marker-scan}"

fail() {
echo "::error::$1" >&2
exit 1
}

if ! command -v jq > /dev/null 2>&1; then
fail "registered case ids: jq is not available, so the marker scan cannot be read."
fi

if [[ ! -d "$SCAN_DIR" ]]; then
fail "registered case ids: '$SCAN_DIR' is not a directory. The alignment tests write the marker scan there when CONFORMANCE_MARKER_SCAN_DIR is set, so either they did not run or they failed before the scan was written."
fi

shopt -s nullglob
scans=("$SCAN_DIR"/*.json)
shopt -u nullglob

if [[ "${#scans[@]}" -eq 0 ]]; then
fail "registered case ids: '$SCAN_DIR' holds no scan file. A scan that never ran is not a scan that found nothing — each test assembly writes a file even when it declares no markers."
fi

ids=""

for scan in "${scans[@]}"; do
if [[ ! -r "$scan" || ! -s "$scan" ]]; then
fail "registered case ids: '$scan' is not a readable, non-empty file."
fi

if ! jq -e . "$scan" > /dev/null 2>&1; then
fail "registered case ids: '$scan' is not valid JSON."
fi

# The emitter names the assembly it scanned. Its absence means the file is not the artifact this
# script is written against, and reading case ids out of it anyway would be guessing.
if ! jq -e '(.assembly | type) == "string" and (.assembly | length) > 0' "$scan" > /dev/null 2>&1; then
fail "registered case ids: '$scan' does not name the assembly it scanned; this is not a marker scan file."
fi

if ! jq -e '(.cases | type) == "array"' "$scan" > /dev/null 2>&1; then
fail "registered case ids: '$scan' has no .cases array."
fi

# An entry with a missing or empty field would drop out of the read below without a word, taking
# a real registration with it.
if ! jq -e 'all(.cases[];
(.case_id | type) == "string" and (.case_id | length) > 0 and
(.declared_by | type) == "string" and (.declared_by | length) > 0)' \
"$scan" > /dev/null 2>&1; then
fail "registered case ids: '$scan' holds an entry with a missing or non-string case_id or declared_by."
fi

ids+="$(jq -r '.cases[] | .case_id' "$scan")"$'\n'
done

# One entry per marker occurrence upstream, so the same id can arrive from two tests, or from two
# assemblies. Sorting unique here is what the consumer expects.
ids="$(printf '%s' "$ids" | grep -v '^$' | sort -u || true)"

if [[ -z "$ids" ]]; then
fail "registered case ids: the scan files in '$SCAN_DIR' record no [Conformance] marker at all. Any check restricted to this list would be vacuously green."
fi

printf '%s\n' "$ids"
58 changes: 58 additions & 0 deletions .github/scripts/fetch-conformance-catalog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# Fetch the conformance catalog at the revision this repo pins.
#
# The catalog lives in github.com/AuthPlane/conformance and is updated
# independently of this repo, so cloning its default branch would let a catalog
# change turn an unrelated PR red here. The ref is pinned instead, single-sourced
# from the tracked .conformance-catalog-ref at the repo root — bump it there when
# adopting new catalog cases, together with the SDK-side coverage for them, so a
# catalog change can never break CI on its own.
#
# This script exists because the read/guard/fetch sequence is needed by more than
# one workflow (ci.yml, release.yml and conformance-catalog-drift.yml). Keeping
# it inline in each meant the guard could be tightened in one and not the others;
# the pin was single-sourced but the logic reading it was not.
#
# Clones into $RUNNER_TEMP — outside $GITHUB_WORKSPACE — so the catalog stays out
# of the working tree: it must never reach dotnet's source discovery or a
# coverage glob, and `git add -A` in the release commit must never stage it.
#
# Requires: GITHUB_WORKSPACE, RUNNER_TEMP.
#
# Optional: CONFORMANCE_CATALOG_DEST overrides the clone directory. The drift
# workflow needs the pinned catalog and the catalog tip side by side in the
# same job to compare case bodies, so it cannot let both land on the default
# path. Every other caller leaves it unset and gets $RUNNER_TEMP/conformance.

set -euo pipefail

: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}"
: "${RUNNER_TEMP:?RUNNER_TEMP must be set}"

REF_FILE="$GITHUB_WORKSPACE/.conformance-catalog-ref"
DEST="${CONFORMANCE_CATALOG_DEST:-$RUNNER_TEMP/conformance}"
CATALOG_REPO="https://github.com/AuthPlane/conformance.git"

if [[ ! -f "$REF_FILE" ]]; then
echo "::error::$REF_FILE is missing; the conformance catalog revision is unpinned"
exit 1
fi

CONFORMANCE_CATALOG_REF="$(tr -d '[:space:]' < "$REF_FILE")"

# Guard against un-pinning: the ref must be a full commit SHA, not a branch or
# tag name, either of which would silently track a moving target.
if ! grep -Eq '^[0-9a-f]{40}$' <<< "$CONFORMANCE_CATALOG_REF"; then
echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA, got '$CONFORMANCE_CATALOG_REF'"
exit 1
fi

git init -q "$DEST"
if ! git -C "$DEST" fetch --depth=1 "$CATALOG_REPO" "$CONFORMANCE_CATALOG_REF"; then
echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"
exit 1
fi
git -C "$DEST" checkout -q FETCH_HEAD

echo "Conformance catalog checked out at $CONFORMANCE_CATALOG_REF in $DEST"
26 changes: 10 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,16 @@ jobs:
- name: Check out shared conformance catalog (out of tree)
# Pinned by SHA (was: clone of the latest default branch). The single
# source of truth for the ref is the tracked `.conformance-catalog-ref`
# file at the repo root, also read by release.yml — bump it when
# adopting new catalog cases, together with the SDK-side conformance
# coverage, so a catalog change can never break CI on its own. The
# Checkout step above must precede this read.
shell: bash
run: |
CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
# Guard the pin before fetching: a non-SHA value would silently
# un-pin CI to whatever ref resolves at fetch time.
grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
git init -q "$RUNNER_TEMP/conformance"
git -C "$RUNNER_TEMP/conformance" \
fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \
|| { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; }
git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD
# file at the repo root, also read by release.yml and the drift
# workflow — bump it when adopting new catalog cases, together with the
# SDK-side conformance coverage, so a catalog change can never break CI
# on its own. The Checkout step above must precede this read.
#
# The read/guard/fetch sequence lives in a script because three
# workflows need it. Inline in each, the 40-hex guard could be
# tightened in one and not the others: the pin was single-sourced but
# the logic reading it was not.
run: .github/scripts/fetch-conformance-catalog.sh

- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
Expand Down
139 changes: 127 additions & 12 deletions .github/workflows/conformance-catalog-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,31 @@ name: Conformance catalog drift
#
# PR and release CI pin the catalog to the SHA in `.conformance-catalog-ref`, so
# a new catalog case can never break CI on its own. The trade-off is that new
# cases go unnoticed until someone bumps the ref. This job closes that gap: on a
# weekly schedule it checks out the catalog's *default* branch (latest,
# unpinned), points the harness at it, and runs the same alignment assertion
# (Authplane.Tests.ConformanceCatalogAlignmentTests) that PR CI runs against the
# pinned catalog. The only difference is which catalog it reads.
# cases go unnoticed until someone bumps the ref. This job closes that gap on a
# weekly schedule.
#
# This workflow has no `pull_request` trigger, so a failing scheduled run cannot
# block a PR. It deliberately FAILS on drift, so the run turns red and the
# `::warning::` plus job-summary note are not buried in an otherwise-green run —
# prompting a coordinated ref bump alongside the SDK-side coverage.
#
# The job runs TWO checks, because they see different things:
#
# 1. Case-ID alignment against the tip. It checks out the catalog's *default*
# branch (latest, unpinned), points the harness at it, and runs the same
# alignment assertion (ConformanceCatalogAlignmentTests) that PR CI runs
# against the pinned catalog. It reports cases ADDED to the catalog that
# this SDK does not yet cover, and markers naming a case the catalog no
# longer has. This is a set comparison over ids.
#
# 2. Case-BODY drift for the cases this SDK registers. It reports a case that
# was re-tightened IN PLACE — same id, changed requirement. Check 1 is
# blind to this by construction: the id set is unchanged, so the case still
# looks adopted while the requirement underneath it has moved, and the SDK
# keeps declaring conformance to wording nothing verified it against. That
# has already happened once, to the metadata jwks_uri rotation case.
# Check 2 needs the catalog at BOTH refs in the same job, which is why the
# pinned catalog is fetched here alongside the tip.

on:
schedule:
Expand Down Expand Up @@ -110,21 +125,121 @@ jobs:
cat "$log"
exit "$status"

# Runs even when the alignment step fails the job, so the `::warning::` and
# The catalog at the ref this repo PINS, alongside the tip cloned above.
# The body check needs both to compare anything; with only the tip it
# would have nothing to compare against and could report nothing but
# green. Reuses the same fetch ci.yml and release.yml use — including its
# 40-hex-SHA guard and its unreachable-ref failure — rather than a third
# copy of that logic that could be tightened in one place and not the
# others. CONFORMANCE_CATALOG_DEST keeps it off the default path, which
# the tip clone above already occupies.
#
# Placed AFTER the alignment check, with if: always(), so the two checks
# fail independently. Only check 2 reads the pinned catalog. Run before
# `align`, a failed fetch — a transient error on this second clone, or a
# pinned SHA that has become unreachable — would skip `Setup .NET` and
# `align` (neither carries an `if:`, so both default to `success()`) and
# silently drop the id-level check that ran on its own before check 2
# existed. `Report drift` would then find no alignment.log and report the
# skip as a build or harness problem, which is the wrong diagnosis for a
# check that never started.
- name: Fetch pinned conformance catalog (out of tree)
if: always()
env:
CONFORMANCE_CATALOG_DEST: ${{ runner.temp }}/conformance-pinned
run: .github/scripts/fetch-conformance-catalog.sh

# The ids for check 2. Registration in this repo is a [Conformance]
# attribute, and the ids come from reflection over the compiled
# attributes — the same scan the alignment assertion above is written
# against — rather than from a match over the test sources, so the two
# cannot disagree about what registered.
#
# Run against the PINNED catalog, not the tip. The scan itself does not
# read the catalog, but the alignment assertion in the same test class
# does, and pointing it at the pin keeps this step independent of the step
# above, which is EXPECTED to go red whenever the tip has drifted.
#
# if: always() because of that: the id-level check failing is the normal
# way this job reports, and the body check must still run after it.
- name: Collect the case ids this SDK registers
if: always()
shell: bash
env:
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml
CONFORMANCE_MARKER_SCAN_DIR: ${{ runner.temp }}/conformance-marker-scan
run: |
# Discard anything an earlier step left behind. If the run below fails
# to produce a new scan, the id script must find nothing rather than
# silently read a stale one and describe the wrong commit.
rm -rf "$CONFORMANCE_MARKER_SCAN_DIR"

status=0
for proj in tests/Authplane.Tests/Authplane.Tests.csproj \
tests/Authplane.Mcp.Tests/Authplane.Mcp.Tests.csproj; do
dotnet test "$proj" \
--configuration Release \
--filter "FullyQualifiedName~ConformanceCatalogAlignmentTests" \
-- RunConfiguration.TreatNoTestsAsError=true \
>>"$RUNNER_TEMP/pinned-suite.log" 2>&1 || status=$?
done
if [ "$status" -ne 0 ]; then
# Not this job's signal to raise: a suite that fails against its own
# pinned catalog is red on every PR in ci.yml already. Surfaced, and
# the id list still comes from THIS run's scan, never an older one.
echo "::warning::The alignment tests did not pass against the pinned catalog (exit $status). This job reports catalog drift, not suite health — see ci.yml. Log tail follows."
tail -n 40 "$RUNNER_TEMP/pinned-suite.log"
fi

.github/scripts/conformance-registered-case-ids.sh \
> "$RUNNER_TEMP/registered-case-ids.txt"
echo "This SDK registers $(wc -l < "$RUNNER_TEMP/registered-case-ids.txt") conformance case(s)."

# Check 2. Fails the job when a case this SDK registers changed body
# between the pinned ref and the tip, and fails just as loudly when it
# cannot do that comparison at all.
- name: Check pinned case bodies against the catalog tip
id: bodies
if: always()
shell: bash
env:
PINNED_CATALOG: ${{ runner.temp }}/conformance-pinned/oauth-sdk-conformance-catalog.yaml
TIP_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
REGISTERED_IDS: ${{ runner.temp }}/registered-case-ids.txt
COVERAGE_DIR: "tests/"
run: |
DRIFT_SUMMARY="$GITHUB_STEP_SUMMARY" \
.github/scripts/conformance-case-body-drift.sh

# Runs even when an earlier step fails the job, so the `::warning::` and
# the job summary are always written on drift. A `failure` outcome alone
# does not mean drift — the step also fails on a compile error or a NuGet
# restore failure. Real drift is identified by the marker the assertion
# writes into its message (ConformanceCatalogAlignment.DriftMarker);
# does not mean drift — the alignment step also fails on a compile error or
# a NuGet restore failure. Real drift is identified by the marker the
# assertion writes into its message (ConformanceCatalogAlignment.DriftMarker);
# anything else is reported as an infrastructure problem, as are
# skipped/cancelled outcomes, where an earlier step failed and the
# alignment never ran.
- name: Report drift
if: always()
shell: bash
run: |
pinned="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")"
grep -Eq '^[0-9a-f]{40}$' <<<"$pinned" \
|| { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; }
# Quoted for the reader only. The 40-hex guard on this value lives in
# fetch-conformance-catalog.sh, which this workflow now runs, so a
# malformed ref turns that step red with its own message instead of
# failing the reporting step and taking the drift verdict with it.
pinned="$(tr -d '[:space:]' < "$GITHUB_WORKSPACE/.conformance-catalog-ref")"

# The body check writes its own detail into the step summary when it
# finds something. Recorded here either way, so a green run states that
# both checks ran rather than only the one that prints on success — a
# check whose silence is indistinguishable from its absence is how this
# class went unnoticed in the first place.
if [ "${{ steps.bodies.outcome }}" = "success" ]; then
echo "### Conformance case bodies: no registered case changed shape under an unchanged id." >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::The pinned case-body check did not pass (outcome: ${{ steps.bodies.outcome }}). Either a case this SDK registers was re-tightened under the same id, or the check could not run. Read its step log — it names the case."
fi

case "${{ steps.align.outcome }}" in
success)
{
Expand Down
Loading
Loading