diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c639a213..1db50c2c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,6 +24,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
+ - name: Isolate NuGet cache
+ run: echo "NUGET_PACKAGES=$RUNNER_TEMP/ci-nuget" >> "$GITHUB_ENV"
+
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
@@ -35,7 +38,9 @@ jobs:
run: dotnet tool restore
- name: Restore dependencies
- run: dotnet restore RestClient.sln
+ run: |
+ python3 scripts/release.py pack-analyzer --output .artifacts/packages
+ dotnet restore RestClient.sln --source "$PWD/.artifacts/packages" --source https://api.nuget.org/v3/index.json
- name: Check code formatting with CSharpier
run: dotnet csharpier --check .
@@ -61,6 +66,24 @@ jobs:
- name: Run F# tests outside the solution
run: dotnet test RestClient.Net.FsTest/RestClient.Net.FsTest.fsproj --configuration Release --verbosity normal
+ - name: Test release ordering and artifact verification
+ run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
+
+ - name: Build and test release packages before publication
+ id: release_versions
+ run: |
+ python3 scripts/release.py versions >> "$GITHUB_OUTPUT"
+ python3 scripts/release.py pack --output .artifacts/release
+
+ - name: Verify fixed analyzer through package consumers
+ env:
+ CLIENT_VERSION: ${{ steps.release_versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.release_versions.outputs.analyzer_version }}
+ run: |
+ for framework in net8.0 net9.0; do
+ python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
+ done
+
- name: Cleanup Docker containers
if: always()
run: |
diff --git a/.github/workflows/publish-exhaustion.yml b/.github/workflows/publish-exhaustion.yml
index 07986957..5dfbc206 100644
--- a/.github/workflows/publish-exhaustion.yml
+++ b/.github/workflows/publish-exhaustion.yml
@@ -7,50 +7,76 @@ on:
workflow_dispatch:
inputs:
version:
- description: 'Version number (e.g., 1.0.0)'
+ description: 'Exhaustion version (e.g., 1.0.1)'
required: true
type: string
+permissions:
+ contents: read
+
+concurrency:
+ group: nuget-release-${{ github.repository }}
+ cancel-in-progress: false
+
jobs:
publish:
runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ DOTNET_PROCESSOR_COUNT: '2'
steps:
- - name: Checkout code
+ - name: Checkout release source
uses: actions/checkout@v4
+ - name: Isolate NuGet cache
+ run: echo "NUGET_PACKAGES=$RUNNER_TEMP/release-nuget" >> "$GITHUB_ENV"
+
+ - name: Resolve and validate release versions
+ id: versions
+ env:
+ REQUESTED_VERSION: ${{ inputs.version || github.ref_name }}
+ run: python3 scripts/release.py versions --analyzer-version "$REQUESTED_VERSION" >> "$GITHUB_OUTPUT"
+
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.0.x'
-
- - name: Restore dependencies
- run: dotnet restore Exhaustion/Exhaustion.csproj
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
- - name: Build
- run: dotnet build Exhaustion/Exhaustion.csproj --configuration Release --no-restore
-
- - name: Set version from tag
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
- run: |
- VERSION=${GITHUB_REF#refs/tags/exhaustion-v}
- echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV
+ - name: Test release ordering and artifact verification
+ run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
- - name: Set version from input
- if: github.event_name == 'workflow_dispatch'
- run: |
- echo "PACKAGE_VERSION=${{ vars.PACKAGE_VERSION }}" >> $GITHUB_ENV
+ - name: Build analyzer and local consumer package
+ env:
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: python3 scripts/release.py pack --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
- - name: Pack
- run: dotnet pack Exhaustion/Exhaustion.csproj --configuration Release --no-build --output ./packages /p:Version=${{ env.PACKAGE_VERSION }}
+ - name: Run analyzer regressions
+ env:
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: dotnet test Exhaustion.Tests/Exhaustion.Tests.csproj --configuration Release --verbosity normal -p:ExhaustionVersion="$ANALYZER_VERSION"
- - name: Publish to NuGet
- run: dotnet nuget push ./packages/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
+ - name: Verify analyzer through the consumer package
env:
- NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: |
+ for framework in net8.0 net9.0; do
+ python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
+ done
- - name: Upload package artifact
+ - name: Upload verified analyzer package
uses: actions/upload-artifact@v4
with:
name: exhaustion-nuget-package
- path: ./packages/*.nupkg
+ path: .artifacts/release/Exhaustion.*.nupkg
+ if-no-files-found: error
+
+ - name: Publish and verify analyzer
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: python3 scripts/release.py publish-analyzer --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
diff --git a/.github/workflows/publish-restclient.yml b/.github/workflows/publish-restclient.yml
index 65092831..d6f63608 100644
--- a/.github/workflows/publish-restclient.yml
+++ b/.github/workflows/publish-restclient.yml
@@ -1,4 +1,4 @@
-name: Publish RestClient.Net to NuGet
+name: Release RestClient.Net and Exhaustion
on:
push:
@@ -7,50 +7,86 @@ on:
workflow_dispatch:
inputs:
version:
- description: 'Version number (e.g., 6.0.0)'
+ description: 'RestClient.Net version (e.g., 7.3.1); ExhaustionVersion comes from Directory.Build.props'
required: true
type: string
+permissions:
+ contents: read
+
+concurrency:
+ group: nuget-release-${{ github.repository }}
+ cancel-in-progress: false
+
jobs:
publish:
runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ DOTNET_PROCESSOR_COUNT: '2'
steps:
- - name: Checkout code
+ - name: Checkout release source
uses: actions/checkout@v4
+ - name: Isolate NuGet cache
+ run: echo "NUGET_PACKAGES=$RUNNER_TEMP/release-nuget" >> "$GITHUB_ENV"
+
+ - name: Resolve and validate release versions
+ id: versions
+ env:
+ REQUESTED_VERSION: ${{ inputs.version || github.ref_name }}
+ run: python3 scripts/release.py versions --client-version "$REQUESTED_VERSION" >> "$GITHUB_OUTPUT"
+
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.0.x'
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
- - name: Restore dependencies
- run: dotnet restore RestClient.Net/RestClient.Net.csproj
+ - name: Test release ordering and artifact verification
+ run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
- - name: Build
- run: dotnet build RestClient.Net/RestClient.Net.csproj --configuration Release --no-restore
+ - name: Build both release packages from this commit
+ env:
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: python3 scripts/release.py pack --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
- - name: Set version from tag
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
- run: |
- VERSION=${GITHUB_REF#refs/tags/restclient-v}
- echo "REST_CLIENT_DOTNET_VERSION=$VERSION" >> $GITHUB_ENV
+ - name: Run analyzer regressions
+ run: dotnet test Exhaustion.Tests/Exhaustion.Tests.csproj --configuration Release --verbosity normal
- - name: Set version from input
- if: github.event_name == 'workflow_dispatch'
+ - name: Verify actual packages in isolated consumers
+ env:
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
run: |
- echo "REST_CLIENT_DOTNET_VERSION=${{ vars.REST_CLIENT_DOTNET_VERSION }}" >> $GITHUB_ENV
+ for framework in net8.0 net9.0; do
+ python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
+ done
- - name: Pack
- run: dotnet pack RestClient.Net/RestClient.Net.csproj --configuration Release --no-build --output ./packages /p:Version=${{ env.REST_CLIENT_DOTNET_VERSION }}
+ - name: Upload verified release packages
+ uses: actions/upload-artifact@v4
+ with:
+ name: restclient-and-exhaustion-packages
+ path: |
+ .artifacts/release/*.nupkg
+ .artifacts/release/*.snupkg
+ if-no-files-found: error
- - name: Publish to NuGet
- run: dotnet nuget push ./packages/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
+ - name: Publish and verify Exhaustion before RestClient.Net
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: python3 scripts/release.py publish --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
- - name: Upload package artifact
- uses: actions/upload-artifact@v4
- with:
- name: restclient-nuget-package
- path: ./packages/*.nupkg
+ - name: Verify published packages from NuGet in fresh consumers
+ env:
+ CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
+ ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
+ run: |
+ for framework in net8.0 net9.0; do
+ python3 scripts/tests/test_release_packages.py --public-nuget --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
+ done
diff --git a/.gitignore b/.gitignore
index 7cb17efd..e7e9c6f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@ coverage.cobertura.xml
final-check/
test-output-final
nupkgs/
+.artifacts/
+__pycache__/
# Website
Website/node_modules/
diff --git a/AGENTS.md b/AGENTS.md
index 17c6953e..23191bf5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@ The existing dotnet commands are the local equivalents of the Makefile commands
| Purpose | Command |
| --- | --- |
-| Setup | `dotnet tool restore` and `dotnet restore RestClient.sln` |
+| Setup | `dotnet tool restore`, `python3 scripts/release.py pack-analyzer`, then `dotnet restore RestClient.sln --source .artifacts/packages --source https://api.nuget.org/v3/index.json` |
| Build | `dotnet build RestClient.sln --configuration Release --no-restore /warnaserror` |
| Analysis | `dotnet build RestClient.sln --configuration Release --no-restore /p:RunAnalyzers=true /p:TreatWarningsAsErrors=true` |
| Format check | `dotnet csharpier --check .` |
@@ -31,6 +31,8 @@ Use a feature branch and a PR to `main`; derive the PR title and description fro
Dependabot updates accumulate on `dependabot-upgrades`; ordinary CI and CodeQL run on the consolidation PR to `main`, not on each bot bump. Never publish packages or create release tags as part of an ordinary PR.
+When a release is authorized, use `.github/workflows/publish-restclient.yml`. It builds and tests both packages, publishes and verifies `Exhaustion` first, then publishes `RestClient.Net` and verifies a fresh NuGet consumer. `ExhaustionVersion` in `Directory.Build.props` is shared by the analyzer and all references. See `scripts/README.md` for package verification and immutable-version rules.
+
## Integration-test isolation
The Nuclia integration fixture starts Docker Compose and removes its project's containers and volumes. If the host already has services, use an isolated `COMPOSE_PROJECT_NAME` and Compose configuration with non-conflicting ports before running it. Keep the heap-limited child processes in the Exhaustion regression tests intact.
diff --git a/Directory.Build.props b/Directory.Build.props
index d1ea3525..4b42876d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,6 +11,7 @@
$(NoWarn);CA2234;IDE0290;SA1513;SA1010;SA1400;SA1122;SA1124;SA1516;SA1114;SA1005;SA1124
true
latest
+ 1.0.1
@@ -21,9 +22,9 @@
all
runtime; build; native; contentfiles; analyzers
-
+
all
runtime; build; native; contentfiles; analyzers
-
\ No newline at end of file
+
diff --git a/Exhaustion/Exhaustion.csproj b/Exhaustion/Exhaustion.csproj
index 180241ed..e5192e67 100644
--- a/Exhaustion/Exhaustion.csproj
+++ b/Exhaustion/Exhaustion.csproj
@@ -8,7 +8,8 @@
Exhaustion
- 1.0.0
+ $(ExhaustionVersion)
+ false
RestClient.Net Contributors
RestClient.Net
Roslyn analyzer that enforces exhaustive pattern matching for closed type hierarchies in C#
diff --git a/RestClient.Net/RestClient.Net.csproj b/RestClient.Net/RestClient.Net.csproj
index 2e268ff2..44df3de2 100644
--- a/RestClient.Net/RestClient.Net.csproj
+++ b/RestClient.Net/RestClient.Net.csproj
@@ -3,7 +3,7 @@
net8.0;net9.0;netstandard2.1
true
RestClient.Net
- 7.1.2
+ 7.3.1
Christian Findlay
Christian Findlay
The safest way to make REST calls in C#. Functional HTTP client library with Result types, exhaustiveness checking, and zero exceptions.
@@ -22,8 +22,8 @@
all
-
-
+
+ none
all
@@ -31,4 +31,4 @@
-
\ No newline at end of file
+
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..cdaeba67
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,43 @@
+# Verified package releases
+
+The `Release RestClient.Net and Exhaustion` GitHub Action accepts a RestClient.Net
+version or a `restclient-v*` tag. It publishes both packages from the checked-out
+commit. It uses the requested version directly, never a repository version variable.
+
+`ExhaustionVersion` in `Directory.Build.props` controls the analyzer package version
+and the dependency in every RestClient.Net target framework. Increment it when
+changing the analyzer. RestClient.Net's project version records the next client
+release; a manual release input overrides it consistently during restore, build,
+and pack.
+
+Before publishing, the action runs analyzer regressions and installs the packed
+RestClient.Net into separate consumers with fresh NuGet caches. These consumers
+reference only RestClient.Net: incomplete switches must report `EXHAUSTION001`,
+complete switches must compile, and the issue #146 hierarchy must finish with the
+bounded `EXHAUSTION002` diagnostic. Consumer processes have time and heap limits.
+
+The action publishes Exhaustion first and waits until its exact analyzer DLL is
+downloadable and its version is indexed. Only then can it publish RestClient.Net.
+It verifies both published packages and repeats the consumer checks using only
+nuget.org. The standalone Exhaustion action uses the same packaging and verification
+helpers. Both publishers share a concurrency group.
+
+NuGet versions are immutable. A retry can reuse an existing version only if its
+DLLs, package identity, and dependencies match this build. A mismatch fails the
+release; increment the affected package version instead of accepting an older DLL.
+NuGet's signing changes are excluded from the binary comparison.
+
+## Local verification
+
+```sh
+python3 scripts/release.py pack --output .artifacts/release
+python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
+python3 scripts/tests/test_release_packages.py --packages .artifacts/release \
+ --restclient-version 7.3.1 --exhaustion-version 1.0.1
+```
+
+Packaging uses a new restore cache and separate build output so an existing local
+package cannot hide a stale analyzer. CI bootstraps the unpublished analyzer into
+`.artifacts/packages` before restoring the solution, allowing the complete suite to
+run before a new analyzer version exists on NuGet. These verification commands do
+not publish packages.
diff --git a/scripts/release.py b/scripts/release.py
new file mode 100644
index 00000000..2fbd4181
--- /dev/null
+++ b/scripts/release.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""Build and publish a verified RestClient.Net / Exhaustion release."""
+
+import argparse
+import io
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+import zipfile
+
+
+ROOT = Path(__file__).resolve().parents[1]
+NUGET_SOURCE = "https://api.nuget.org/v3/index.json"
+NUGET_PACKAGES = "https://api.nuget.org/v3-flatcontainer"
+
+
+def normalize_version(value: str, tag_prefix: str = "") -> str:
+ if tag_prefix and value.startswith(tag_prefix):
+ value = value[len(tag_prefix):]
+ number = r"(?:0|[1-9][0-9]*)"
+ if not re.fullmatch(rf"{number}\.{number}\.{number}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", value):
+ raise ValueError(f"Invalid release version: {value!r}")
+ if "-" in value:
+ for identifier in value.split("-", 1)[1].split("."):
+ if identifier.isdigit() and len(identifier) > 1 and identifier.startswith("0"):
+ raise ValueError(f"Invalid release version: {value!r}")
+ return value
+
+
+def project_versions(client_version=None, analyzer_version=None):
+ client = ET.parse(ROOT / "RestClient.Net/RestClient.Net.csproj").findtext(".//Version") if client_version is None else client_version
+ analyzer = ET.parse(ROOT / "Directory.Build.props").findtext(".//ExhaustionVersion") if analyzer_version is None else analyzer_version
+ return normalize_version(client, "restclient-v"), normalize_version(analyzer, "exhaustion-v")
+
+
+def _run(command, env=None):
+ print("Running:", " ".join(map(str, command)), flush=True)
+ subprocess.run(list(map(str, command)), cwd=ROOT, env=env, check=True, timeout=600)
+
+
+def pack_analyzer(output: Path, analyzer_version: str, env=None, artifacts=None):
+ output.mkdir(parents=True, exist_ok=True)
+ command = ["dotnet", "pack", "Exhaustion/Exhaustion.csproj", "--configuration", "Release",
+ "--output", output, f"-p:ExhaustionVersion={analyzer_version}",
+ f"-p:Version={analyzer_version}", "-p:GeneratePackageOnBuild=false"]
+ if artifacts:
+ command.extend(["--artifacts-path", artifacts,
+ f"-p:PathMap={artifacts.parent}=/_/work%2C{ROOT}=/_/src"])
+ _run(command, env)
+
+
+def build_release(output: Path, client_version: str, analyzer_version: str):
+ # A new cache and build directory prevent an older same-version package or
+ # previous build output from masquerading as the analyzer being released.
+ with tempfile.TemporaryDirectory(prefix="restclient-release-") as temporary:
+ temporary = Path(temporary)
+ env = dict(os.environ, NUGET_PACKAGES=str(temporary / "nuget"))
+ artifacts = temporary / "build"
+ pack_analyzer(output, analyzer_version, env, artifacts)
+ properties = [f"-p:Version={client_version}", f"-p:ExhaustionVersion={analyzer_version}",
+ f"-p:ArtifactsPath={artifacts}", "-p:UseArtifactsOutput=true",
+ f"-p:PathMap={temporary}=/_/work%2C{ROOT}=/_/src"]
+ _run(["dotnet", "restore", "RestClient.Net/RestClient.Net.csproj",
+ "--source", output, "--source", NUGET_SOURCE, *properties], env)
+ _run(["dotnet", "pack", "RestClient.Net/RestClient.Net.csproj",
+ "--configuration", "Release", "--no-restore", "--output", output,
+ *properties], env)
+
+
+def _package_metadata(package):
+ with zipfile.ZipFile(package) as archive:
+ specs = [name for name in archive.namelist() if name.endswith(".nuspec")]
+ if len(specs) != 1:
+ raise ValueError("Package must contain exactly one nuspec")
+ metadata = ET.fromstring(archive.read(specs[0])).find("{*}metadata")
+ if metadata is None:
+ raise ValueError("Package is missing metadata")
+ identity = metadata.findtext("{*}id")
+ version = metadata.findtext("{*}version")
+ dependencies = metadata.find("{*}dependencies")
+ dependency_groups = []
+ if dependencies is not None:
+ for group in dependencies:
+ dependency_groups.append((tuple(sorted(group.attrib.items())),
+ tuple(sorted(tuple(sorted(item.attrib.items())) for item in group))))
+ libraries = {name: archive.read(name) for name in archive.namelist() if name.lower().endswith(".dll")}
+ return identity, version, tuple(sorted(dependency_groups)), libraries
+
+
+def verify_published_package(expected: Path, downloaded: bytes) -> None:
+ expected_id, expected_version, expected_dependencies, expected_libraries = _package_metadata(expected)
+ actual_id, actual_version, actual_dependencies, actual_libraries = _package_metadata(io.BytesIO(downloaded))
+ if (actual_id, actual_version) != (expected_id, expected_version):
+ raise ValueError("Published package identity or version does not match the release")
+ if actual_dependencies != expected_dependencies:
+ raise ValueError("Published package dependencies do not match the release")
+ if not expected_libraries or actual_libraries != expected_libraries:
+ raise ValueError("Published package DLLs differ from this build; bump the package version before publishing")
+
+
+def push_package(package: Path) -> None:
+ api_key = os.environ.get("NUGET_API_KEY")
+ if not api_key:
+ raise RuntimeError("NUGET_API_KEY is required for publication")
+ if not package.is_file():
+ raise ValueError(f"Release package does not exist: {package}")
+ # Do not print the command or propagate a CalledProcessError containing the key.
+ print(f"Publishing {package.name}", flush=True)
+ try:
+ subprocess.run(["dotnet", "nuget", "push", str(package), "--api-key", api_key,
+ "--source", NUGET_SOURCE, "--skip-duplicate"],
+ cwd=ROOT, check=True, timeout=180)
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
+ raise RuntimeError(f"NuGet publication failed for {package.name}") from None
+
+
+def wait_for_public_package(package: Path, timeout: float = 600, poll_interval: float = 15) -> None:
+ identity, version, _, _ = _package_metadata(package)
+ identifier = identity.lower()
+ normalized = version.lower()
+ base = f"{NUGET_PACKAGES}/{identifier}"
+ url = f"{base}/{normalized}/{identifier}.{normalized}.nupkg"
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(f"NuGet did not make {identity} {version} available within {timeout}s")
+ try:
+ with urllib.request.urlopen(url, timeout=min(30, remaining)) as response:
+ verify_published_package(package, response.read())
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("NuGet indexing verification exceeded its deadline")
+ with urllib.request.urlopen(f"{base}/index.json", timeout=min(30, remaining)) as response:
+ versions = json.load(response)["versions"]
+ if normalized in [item.lower() for item in versions]:
+ print(f"Verified published {identity} {version}", flush=True)
+ return
+ except urllib.error.HTTPError as error:
+ if error.code not in (404, 429, 500, 502, 503, 504):
+ raise
+ except (urllib.error.URLError, TimeoutError):
+ pass
+ if time.monotonic() >= deadline:
+ raise TimeoutError(f"NuGet did not make {identity} {version} available within {timeout}s")
+ print(f"Waiting for NuGet to index {identity} {version}", flush=True)
+ time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
+
+
+def publish_release(packages: Path, client_version: str, analyzer_version: str) -> None:
+ # Client publication cannot start until the exact fixed analyzer is public.
+ for package in (packages / f"Exhaustion.{analyzer_version}.nupkg",
+ packages / f"RestClient.Net.{client_version}.nupkg"):
+ push_package(package)
+ wait_for_public_package(package)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("command", choices=["versions", "pack-analyzer", "pack", "publish-analyzer", "publish"])
+ parser.add_argument("--client-version")
+ parser.add_argument("--analyzer-version")
+ parser.add_argument("--output", type=Path, default=ROOT / ".artifacts/packages")
+ args = parser.parse_args()
+ client_version, analyzer_version = project_versions(args.client_version, args.analyzer_version)
+ output = args.output.resolve()
+ if args.command == "versions":
+ print(f"client_version={client_version}\nanalyzer_version={analyzer_version}")
+ elif args.command == "pack-analyzer":
+ pack_analyzer(output, analyzer_version)
+ elif args.command == "pack":
+ build_release(output, client_version, analyzer_version)
+ elif args.command == "publish-analyzer":
+ package = output / f"Exhaustion.{analyzer_version}.nupkg"
+ push_package(package)
+ wait_for_public_package(package)
+ elif args.command == "publish":
+ publish_release(output, client_version, analyzer_version)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tests/README.md b/scripts/tests/README.md
new file mode 100644
index 00000000..7a1bf745
--- /dev/null
+++ b/scripts/tests/README.md
@@ -0,0 +1,29 @@
+# Release package regressions
+
+Run against the actual packages that will be published:
+
+```sh
+python3 scripts/tests/test_release_packages.py \
+ --packages artifacts/packages \
+ --restclient-version 7.3.1 \
+ --exhaustion-version 1.0.1
+```
+
+Requires Python 3, the .NET SDK, and NuGet access for supporting dependencies.
+Both `.nupkg` files must exist in the specified directory. These checks inspect
+the packed dependency metadata and analyzer asset, then restore an independent
+consumer outside this repository with a fresh NuGet cache. Its only package
+reference is RestClient.Net; source mapping forces both release packages to come
+from the supplied directory.
+
+After publishing, repeat with `--public-nuget` instead of `--packages `.
+That inspects the downloaded public artifacts and restores the consumer solely
+from nuget.org into a fresh cache, proving the published dependency chain works.
+
+The consumer builds an incomplete switch, fixes it, and reintroduces the missing
+case to prove the analyzer runs throughout normal edits. Both switch expressions
+and statements exercise the 25-leaf recursive hierarchy from issue #146 and must
+produce the bounded EXHAUSTION002 warning. Compiler invocations have a 512 MiB
+managed-heap limit, a 90-second timeout, and disabled shared compiler/build servers.
+Timeouts terminate the complete process tree. Temporary projects and packages are
+cleaned up after the run.
diff --git a/scripts/tests/test_release_orchestration.py b/scripts/tests/test_release_orchestration.py
new file mode 100644
index 00000000..fa5da244
--- /dev/null
+++ b/scripts/tests/test_release_orchestration.py
@@ -0,0 +1,526 @@
+"""Exercise immutable package validation and dependency-first publication."""
+
+from __future__ import annotations
+
+import importlib.util
+import io
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest import mock
+import urllib.error
+from xml.sax.saxutils import escape
+import zipfile
+
+
+SCRIPT_PATH = Path(__file__).resolve().parents[1] / "release.py"
+SPEC = importlib.util.spec_from_file_location("release_under_test", SCRIPT_PATH)
+if SPEC is None or SPEC.loader is None:
+ raise RuntimeError(f"Cannot load release helper: {SCRIPT_PATH}")
+release = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(release)
+
+
+ANALYZER_DLL = "analyzers/dotnet/cs/Exhaustion.dll"
+CLIENT_DLLS = {
+ "lib/net8.0/RestClient.Net.dll": b"net8-client-fixed-analyzer-dependency",
+ "lib/net9.0/RestClient.Net.dll": b"net9-client-fixed-analyzer-dependency",
+ "lib/netstandard2.1/RestClient.Net.dll": b"netstandard-client-fixed-analyzer-dependency",
+}
+FRAMEWORKS = ("net8.0", "net9.0", ".NETStandard2.1")
+
+
+def package_bytes(
+ package_id: str = "Exhaustion",
+ version: str = "1.0.1",
+ *,
+ dlls: dict[str, bytes] | None = None,
+ dependencies: tuple[tuple[str, str, str, str], ...] = (),
+ signed: bool = False,
+) -> bytes:
+ """Create actual NuGet ZIPs; DLL payload differences remain observable."""
+ if dlls is None:
+ dlls = {ANALYZER_DLL: b"bounded-analyzer-source-146"}
+ dependency_groups = "".join(
+ ''.format(
+ escape(framework),
+ escape(dependency),
+ escape(required_version),
+ f' exclude="{escape(excluded)}"' if excluded else "",
+ )
+ for framework, dependency, required_version, excluded in dependencies
+ )
+ nuspec = (
+ ''
+ ''
+ ""
+ f"{escape(package_id)}{escape(version)}"
+ "RestClient.NetRelease regression fixture"
+ f"{dependency_groups}"
+ ""
+ )
+ archive = io.BytesIO()
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package:
+ package.writestr(f"{package_id}.nuspec", nuspec)
+ for path, payload in dlls.items():
+ package.writestr(path, payload)
+ package.writestr("README.md", "Release regression fixture")
+ if signed:
+ package.writestr(".signature.p7s", b"nuget-added-repository-signature")
+ package.comment = b"NuGet may repackage and sign uploaded artifacts"
+ return archive.getvalue()
+
+
+def analyzer_dependencies(
+ version: str = "1.0.1", excluded: str = ""
+) -> tuple[tuple[str, str, str, str], ...]:
+ return tuple((framework, "Exhaustion", version, excluded) for framework in FRAMEWORKS)
+
+
+class VersionValidationTests(unittest.TestCase):
+ def test_accepts_releasable_versions_and_removes_only_the_requested_tag_prefix(self):
+ cases = (
+ ("1.0.1", "", "1.0.1"),
+ ("7.3.1", "restclient-v", "7.3.1"),
+ ("restclient-v7.3.1", "restclient-v", "7.3.1"),
+ ("exhaustion-v1.0.1", "exhaustion-v", "1.0.1"),
+ ("1.1.0-rc.1", "", "1.1.0-rc.1"),
+ ("0.0.0", "", "0.0.0"),
+ )
+ for supplied, prefix, expected in cases:
+ with self.subTest(supplied=supplied, prefix=prefix):
+ actual = release.normalize_version(supplied, prefix)
+ self.assertEqual(expected, actual)
+ self.assertIsInstance(actual, str)
+ self.assertNotIn("/", actual)
+ self.assertNotIn("\n", actual)
+
+ def test_rejects_malformed_versions_before_any_external_command_can_run(self):
+ invalid_versions = (
+ "",
+ "1",
+ "1.0",
+ "1.0.1.0",
+ "-1.0.1",
+ "01.0.1",
+ "1.00.1",
+ "1.0.01",
+ "1.0.1-",
+ "1.0.1-rc..1",
+ "1.0.1-01",
+ "1.0.1-rc.01",
+ "v1.0.1",
+ "1.0.1/../../different-package",
+ "1.0.1\nOTHER_VERSION=1.0.0",
+ "1.0.1\x00",
+ "1.0.1; echo unsafe",
+ "$(echo 1.0.1)",
+ "1.0.1",
+ )
+ for supplied in invalid_versions:
+ with self.subTest(supplied=supplied):
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.normalize_version(supplied)
+
+ def test_cannot_reinterpret_another_packages_tag_as_a_client_release(self):
+ for supplied in ("exhaustion-v1.0.1", "prefix-restclient-v7.3.1", "restclient-v"):
+ with self.subTest(supplied=supplied):
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.normalize_version(supplied, "restclient-v")
+
+ def test_explicit_empty_overrides_cannot_silently_release_the_project_default(self):
+ for client, analyzer in (("", None), (None, ""), ("", "")):
+ with self.subTest(client=client, analyzer=analyzer):
+ with self.assertRaises(ValueError):
+ release.project_versions(client, analyzer)
+
+
+class PublishedArtifactTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory(prefix="restclient-release-tests-")
+ self.addCleanup(self.directory.cleanup)
+ self.packages = Path(self.directory.name)
+
+ def expected_package(self, payload: bytes, filename: str = "Exhaustion.1.0.1.nupkg") -> Path:
+ path = self.packages / filename
+ path.write_bytes(payload)
+ return path
+
+ def test_matching_analyzer_can_be_reused_after_nuget_adds_its_signature(self):
+ expected_bytes = package_bytes()
+ actual_bytes = package_bytes(signed=True)
+ expected = self.expected_package(expected_bytes)
+
+ self.assertNotEqual(expected_bytes, actual_bytes)
+ self.assertIsNone(release.verify_published_package(expected, actual_bytes))
+ self.assertEqual(expected_bytes, expected.read_bytes())
+
+ def test_same_analyzer_version_with_old_dll_is_rejected(self):
+ expected_bytes = package_bytes()
+ expected = self.expected_package(expected_bytes)
+ stale = package_bytes(dlls={ANALYZER_DLL: b"old-unbounded-analyzer-146"}, signed=True)
+
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, stale)
+ self.assertEqual(expected_bytes, expected.read_bytes())
+
+ def test_missing_analyzer_asset_is_rejected_even_when_nuspec_matches(self):
+ expected = self.expected_package(package_bytes())
+ empty_analyzer = package_bytes(dlls={}, signed=True)
+
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, empty_analyzer)
+
+ def test_analyzer_in_wrong_directory_is_not_a_valid_replacement(self):
+ expected = self.expected_package(package_bytes())
+ wrong_layout = package_bytes(
+ dlls={"lib/netstandard2.0/Exhaustion.dll": b"bounded-analyzer-source-146"}
+ )
+
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, wrong_layout)
+
+ def test_wrong_package_identity_or_version_is_rejected_despite_identical_dll(self):
+ expected = self.expected_package(package_bytes())
+ for package_id, version in (("Other.Analyzer", "1.0.1"), ("Exhaustion", "1.0.0")):
+ with self.subTest(package_id=package_id, version=version):
+ downloaded = package_bytes(package_id, version, signed=True)
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, downloaded)
+
+ def test_matching_client_preserves_every_framework_and_fixed_dependency(self):
+ dependencies = analyzer_dependencies()
+ expected = self.expected_package(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=dependencies),
+ "RestClient.Net.7.3.1.nupkg",
+ )
+ downloaded = package_bytes(
+ "RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=dependencies, signed=True
+ )
+
+ self.assertIsNone(release.verify_published_package(expected, downloaded))
+
+ def test_stale_client_analyzer_dependency_is_rejected_for_every_framework(self):
+ dependencies = analyzer_dependencies()
+ expected = self.expected_package(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=dependencies),
+ "RestClient.Net.7.3.1.nupkg",
+ )
+ for affected_framework in FRAMEWORKS:
+ with self.subTest(affected_framework=affected_framework):
+ stale_dependencies = tuple(
+ (framework, package_id, "1.0.0" if framework == affected_framework else version, excluded)
+ for framework, package_id, version, excluded in dependencies
+ )
+ downloaded = package_bytes(
+ "RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=stale_dependencies
+ )
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, downloaded)
+
+ def test_excluding_transitive_analyzers_is_rejected_for_every_framework(self):
+ dependencies = analyzer_dependencies()
+ expected = self.expected_package(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=dependencies),
+ "RestClient.Net.7.3.1.nupkg",
+ )
+ for affected_framework in FRAMEWORKS:
+ with self.subTest(affected_framework=affected_framework):
+ excluded_dependencies = tuple(
+ (framework, package_id, version, "Build,Analyzers" if framework == affected_framework else excluded)
+ for framework, package_id, version, excluded in dependencies
+ )
+ downloaded = package_bytes(
+ "RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=excluded_dependencies
+ )
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, downloaded)
+
+ def test_missing_dependency_group_cannot_silently_disable_one_framework(self):
+ expected = self.expected_package(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=analyzer_dependencies()),
+ "RestClient.Net.7.3.1.nupkg",
+ )
+ downloaded = package_bytes(
+ "RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=analyzer_dependencies()[:-1]
+ )
+
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, downloaded)
+
+ def test_every_client_dll_must_match_the_built_package(self):
+ expected = self.expected_package(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=analyzer_dependencies()),
+ "RestClient.Net.7.3.1.nupkg",
+ )
+ for changed_path in CLIENT_DLLS:
+ with self.subTest(changed_path=changed_path):
+ stale_dlls = {**CLIENT_DLLS, changed_path: b"old-client-build"}
+ downloaded = package_bytes(
+ "RestClient.Net", "7.3.1", dlls=stale_dlls, dependencies=analyzer_dependencies()
+ )
+ with self.assertRaises((ValueError, RuntimeError)):
+ release.verify_published_package(expected, downloaded)
+
+
+class PublicationOrderTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory(prefix="restclient-publication-tests-")
+ self.addCleanup(self.directory.cleanup)
+ self.packages = Path(self.directory.name)
+ self.analyzer = self.packages / "Exhaustion.1.0.1.nupkg"
+ self.client = self.packages / "RestClient.Net.7.3.1.nupkg"
+ self.analyzer.write_bytes(package_bytes())
+ self.client.write_bytes(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=analyzer_dependencies())
+ )
+ self.calls = mock.Mock()
+ self.push = mock.Mock()
+ self.wait = mock.Mock()
+ self.calls.attach_mock(self.push, "push")
+ self.calls.attach_mock(self.wait, "wait")
+ self.addCleanup(mock.patch.stopall)
+ mock.patch.object(release, "push_package", self.push).start()
+ mock.patch.object(release, "wait_for_public_package", self.wait).start()
+
+ def publish(self):
+ release.publish_release(self.packages, "7.3.1", "1.0.1")
+
+ def test_analyzer_is_published_and_verified_before_client_is_published(self):
+ self.publish()
+
+ self.assertEqual(
+ [
+ mock.call.push(self.analyzer),
+ mock.call.wait(self.analyzer),
+ mock.call.push(self.client),
+ mock.call.wait(self.client),
+ ],
+ self.calls.mock_calls,
+ )
+ self.assertEqual(2, self.push.call_count)
+ self.assertEqual(2, self.wait.call_count)
+
+ def test_analyzer_push_failure_cannot_publish_client(self):
+ self.push.side_effect = RuntimeError("analyzer upload failed")
+
+ with self.assertRaisesRegex(RuntimeError, "analyzer upload failed"):
+ self.publish()
+ self.push.assert_called_once_with(self.analyzer)
+ self.wait.assert_not_called()
+ self.assertEqual([mock.call.push(self.analyzer)], self.calls.mock_calls)
+
+ def test_stale_duplicate_analyzer_prevents_client_publication(self):
+ self.wait.side_effect = RuntimeError("published analyzer DLL differs; bump version")
+
+ with self.assertRaisesRegex(RuntimeError, "published analyzer DLL differs"):
+ self.publish()
+ self.push.assert_called_once_with(self.analyzer)
+ self.wait.assert_called_once_with(self.analyzer)
+ self.assertEqual(
+ [mock.call.push(self.analyzer), mock.call.wait(self.analyzer)], self.calls.mock_calls
+ )
+
+ def test_analyzer_availability_timeout_prevents_client_publication(self):
+ self.wait.side_effect = TimeoutError("NuGet index did not expose analyzer")
+
+ with self.assertRaisesRegex(TimeoutError, "NuGet index"):
+ self.publish()
+ self.push.assert_called_once_with(self.analyzer)
+ self.wait.assert_called_once_with(self.analyzer)
+ self.assertEqual(2, len(self.calls.mock_calls))
+
+ def test_matching_already_published_analyzer_allows_idempotent_retry(self):
+ def verify_download(package: Path):
+ if package == self.analyzer:
+ release.verify_published_package(package, package_bytes(signed=True))
+ else:
+ release.verify_published_package(
+ package,
+ package_bytes(
+ "RestClient.Net", "7.3.1", dlls=CLIENT_DLLS,
+ dependencies=analyzer_dependencies(), signed=True,
+ ),
+ )
+
+ self.wait.side_effect = verify_download
+ self.publish()
+
+ self.assertEqual([mock.call(self.analyzer), mock.call(self.client)], self.push.call_args_list)
+ self.assertEqual([mock.call(self.analyzer), mock.call(self.client)], self.wait.call_args_list)
+
+ def test_client_push_failure_is_reported_without_claiming_verification(self):
+ self.push.side_effect = (None, RuntimeError("client upload failed"))
+
+ with self.assertRaisesRegex(RuntimeError, "client upload failed"):
+ self.publish()
+ self.assertEqual([mock.call(self.analyzer), mock.call(self.client)], self.push.call_args_list)
+ self.wait.assert_called_once_with(self.analyzer)
+
+ def test_stale_duplicate_client_is_reported_as_failure(self):
+ self.wait.side_effect = (None, RuntimeError("published client DLL differs"))
+
+ with self.assertRaisesRegex(RuntimeError, "published client DLL differs"):
+ self.publish()
+ self.assertEqual([mock.call(self.analyzer), mock.call(self.client)], self.push.call_args_list)
+ self.assertEqual([mock.call(self.analyzer), mock.call(self.client)], self.wait.call_args_list)
+
+
+class PublicNuGetVerificationTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory(prefix="restclient-public-download-tests-")
+ self.addCleanup(self.directory.cleanup)
+ self.packages = Path(self.directory.name)
+ self.analyzer = self.packages / "Exhaustion.1.0.1.nupkg"
+ self.analyzer.write_bytes(package_bytes())
+ self.download_url = (
+ "https://api.nuget.org/v3-flatcontainer/exhaustion/1.0.1/exhaustion.1.0.1.nupkg"
+ )
+ self.index_url = "https://api.nuget.org/v3-flatcontainer/exhaustion/index.json"
+ self.elapsed = 0.0
+ self.monotonic = self.patch(release.time, "monotonic", side_effect=lambda: self.elapsed)
+ self.sleep = self.patch(release.time, "sleep", side_effect=self.advance_time)
+
+ def patch(self, target, attribute, **options):
+ patcher = mock.patch.object(target, attribute, **options)
+ value = patcher.start()
+ self.addCleanup(patcher.stop)
+ return value
+
+ def advance_time(self, seconds):
+ self.assertGreaterEqual(seconds, 0)
+ self.elapsed += seconds
+
+ @staticmethod
+ def package_response():
+ return io.BytesIO(package_bytes(signed=True))
+
+ @staticmethod
+ def index_response(*versions):
+ return io.BytesIO(json.dumps({"versions": versions}).encode())
+
+ def test_success_requires_both_matching_download_and_indexed_version(self):
+ requests = self.patch(
+ release.urllib.request, "urlopen",
+ side_effect=[self.package_response(), self.index_response("1.0.0", "1.0.1")],
+ )
+
+ self.assertIsNone(release.wait_for_public_package(self.analyzer, timeout=10, poll_interval=3))
+
+ self.assertEqual([self.download_url, self.index_url], [call.args[0] for call in requests.call_args_list])
+ self.assertTrue(all(0 < call.kwargs["timeout"] <= 10 for call in requests.call_args_list))
+ self.sleep.assert_not_called()
+ self.assertEqual(0, self.elapsed)
+
+ def test_expired_deadline_does_not_start_another_download(self):
+ requests = self.patch(release.urllib.request, "urlopen")
+
+ with self.assertRaises(TimeoutError):
+ release.wait_for_public_package(self.analyzer, timeout=0, poll_interval=3)
+
+ requests.assert_not_called()
+ self.sleep.assert_not_called()
+ self.assertEqual(0, self.elapsed)
+
+ def test_unpublished_package_retries_404_then_checks_actual_download_and_index(self):
+ missing = urllib.error.HTTPError(self.download_url, 404, "Not yet published", None, None)
+ self.addCleanup(missing.close)
+ requests = self.patch(
+ release.urllib.request, "urlopen",
+ side_effect=[missing, self.package_response(), self.index_response("1.0.1")],
+ )
+
+ release.wait_for_public_package(self.analyzer, timeout=10, poll_interval=3)
+
+ self.assertEqual(
+ [self.download_url, self.download_url, self.index_url],
+ [call.args[0] for call in requests.call_args_list],
+ )
+ self.sleep.assert_called_once_with(3)
+ self.assertEqual(3, self.elapsed)
+ self.assertTrue(all(0 < call.kwargs["timeout"] <= 7 for call in requests.call_args_list[1:]))
+
+ def test_download_without_index_entry_cannot_be_reported_as_available(self):
+ requests = self.patch(
+ release.urllib.request, "urlopen",
+ side_effect=[
+ self.package_response(), self.index_response("1.0.0"),
+ self.package_response(), self.index_response("1.0.0", "1.0.1"),
+ ],
+ )
+
+ release.wait_for_public_package(self.analyzer, timeout=10, poll_interval=3)
+
+ self.assertEqual(
+ [self.download_url, self.index_url, self.download_url, self.index_url],
+ [call.args[0] for call in requests.call_args_list],
+ )
+ self.sleep.assert_called_once_with(3)
+ self.assertEqual(3, self.elapsed)
+
+ def test_indexing_timeout_bounds_requests_and_sleep_by_the_remaining_deadline(self):
+ def unavailable_index(url, **_):
+ return self.package_response() if url == self.download_url else self.index_response("1.0.0")
+
+ requests = self.patch(release.urllib.request, "urlopen", side_effect=unavailable_index)
+
+ with self.assertRaises(TimeoutError):
+ release.wait_for_public_package(self.analyzer, timeout=5, poll_interval=2)
+
+ self.assertEqual(5, self.elapsed)
+ self.assertEqual([mock.call(2), mock.call(2), mock.call(1)], self.sleep.call_args_list)
+ self.assertEqual([5, 5, 3, 3, 1, 1], [call.kwargs["timeout"] for call in requests.call_args_list])
+ self.assertEqual(3, sum(call.args[0] == self.index_url for call in requests.call_args_list))
+
+ def test_interrupted_download_retries_before_attempting_index_verification(self):
+ interrupted = mock.MagicMock()
+ interrupted.__enter__.return_value.read.side_effect = TimeoutError("download interrupted")
+ requests = self.patch(
+ release.urllib.request, "urlopen",
+ side_effect=[interrupted, self.package_response(), self.index_response("1.0.1")],
+ )
+
+ release.wait_for_public_package(self.analyzer, timeout=10, poll_interval=3)
+
+ self.assertEqual(
+ [self.download_url, self.download_url, self.index_url],
+ [call.args[0] for call in requests.call_args_list],
+ )
+ interrupted.__enter__.return_value.read.assert_called_once_with()
+ self.sleep.assert_called_once_with(3)
+
+ def test_permanent_http_failure_is_reported_without_retrying(self):
+ forbidden = urllib.error.HTTPError(self.download_url, 403, "Forbidden", None, None)
+ self.addCleanup(forbidden.close)
+ requests = self.patch(release.urllib.request, "urlopen", side_effect=forbidden)
+
+ with self.assertRaises(urllib.error.HTTPError) as error:
+ release.wait_for_public_package(self.analyzer, timeout=10, poll_interval=3)
+
+ self.assertIs(forbidden, error.exception)
+ requests.assert_called_once()
+ self.sleep.assert_not_called()
+
+ def test_stale_download_after_404_prevents_client_publication_without_retrying_mismatch(self):
+ missing = urllib.error.HTTPError(self.download_url, 404, "Not yet published", None, None)
+ self.addCleanup(missing.close)
+ stale = io.BytesIO(package_bytes(dlls={ANALYZER_DLL: b"unfixed-analyzer"}, signed=True))
+ requests = self.patch(release.urllib.request, "urlopen", side_effect=[missing, stale])
+ push = self.patch(release, "push_package")
+ client = self.packages / "RestClient.Net.7.3.1.nupkg"
+ client.write_bytes(
+ package_bytes("RestClient.Net", "7.3.1", dlls=CLIENT_DLLS, dependencies=analyzer_dependencies())
+ )
+
+ with self.assertRaisesRegex(ValueError, "DLLs differ"):
+ release.publish_release(self.packages, "7.3.1", "1.0.1")
+
+ push.assert_called_once_with(self.analyzer)
+ self.assertEqual([self.download_url, self.download_url], [call.args[0] for call in requests.call_args_list])
+ self.sleep.assert_called_once_with(15)
+ self.assertEqual(15, self.elapsed)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/tests/test_release_packages.py b/scripts/tests/test_release_packages.py
new file mode 100644
index 00000000..bb57b3c2
--- /dev/null
+++ b/scripts/tests/test_release_packages.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+"""Exercise the actual release packages from an isolated, ordinary NuGet consumer."""
+
+import argparse
+from contextlib import ExitStack
+import json
+import os
+from pathlib import Path
+import re
+import signal
+import subprocess
+import tempfile
+import unittest
+import urllib.request
+import xml.etree.ElementTree as ET
+from xml.sax.saxutils import escape
+import zipfile
+
+
+MINIMUM_FIXED_VERSION = (1, 0, 1)
+OPTIONS = None
+
+
+def version_tuple(value):
+ match = re.match(r"^[\[(]?(\d+)\.(\d+)\.(\d+)", value)
+ if not match:
+ raise AssertionError(f"Expected a semantic version or lower bound, got {value!r}")
+ return tuple(map(int, match.groups()))
+
+
+def read_package(package_id, version):
+ path = OPTIONS.packages / f"{package_id}.{version}.nupkg"
+ if not path.is_file():
+ matches = [p for p in OPTIONS.packages.glob("*.nupkg") if p.name.lower() == path.name.lower()]
+ if len(matches) != 1:
+ raise AssertionError(f"Release package does not exist: {path}")
+ path = matches[0]
+ with zipfile.ZipFile(path) as archive:
+ manifests = [name for name in archive.namelist() if name.endswith(".nuspec")]
+ if len(manifests) != 1:
+ raise AssertionError(f"Expected one manifest in {path}, got {manifests}")
+ return ET.fromstring(archive.read(manifests[0])), archive.namelist()
+
+
+def run_dotnet(arguments, directory, timeout):
+ """Contain regressions in a 512 MiB heap and terminate the entire process tree."""
+ environment = dict(os.environ)
+ environment.update({
+ "DOTNET_GCHeapHardLimit": "0x20000000",
+ "DOTNET_PROCESSOR_COUNT": "2",
+ "DOTNET_CLI_TELEMETRY_OPTOUT": "1",
+ "DOTNET_NOLOGO": "1",
+ "MSBUILDDISABLENODEREUSE": "1",
+ "NUGET_PACKAGES": str(directory / "packages"),
+ })
+ command = ["dotnet", *arguments]
+ # A file keeps an accidentally enormous legacy diagnostic out of Python's heap.
+ with tempfile.TemporaryFile(mode="w+b") as output_file:
+ process = subprocess.Popen(
+ command, cwd=directory, env=environment,
+ stdout=output_file, stderr=subprocess.STDOUT,
+ start_new_session=os.name != "nt",
+ )
+ try:
+ process.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ if os.name == "nt":
+ subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
+ check=False, timeout=10, capture_output=True)
+ else:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait(timeout=10)
+ raise AssertionError(f"Timed out after {timeout}s; killed bounded process tree: {command}")
+ output_size = output_file.tell()
+ output_file.seek(max(0, output_size - 65536))
+ output = output_file.read().decode("utf-8", errors="replace")
+ return process.returncode, output, output_size
+
+
+class ReleasePackagesTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.restclient_manifest, _ = read_package("RestClient.Net", OPTIONS.restclient_version)
+ cls.analyzer_manifest, cls.analyzer_files = read_package("Exhaustion", OPTIONS.exhaustion_version)
+ cls.temporary = tempfile.TemporaryDirectory(prefix="restclient-package-consumer-")
+ cls.addClassCleanup(cls.temporary.cleanup)
+ cls.directory = Path(cls.temporary.name)
+ cls.project = cls.directory / "Consumer.csproj"
+ cls.project.write_text(f'''
+
+ {escape(OPTIONS.framework)}
+ latest
+ enable
+ false
+
+ CS8509
+ EXHAUSTION001
+ false
+
+
+
+
+
+''', encoding="utf-8")
+ if OPTIONS.public_nuget:
+ configuration = '''
+
+
+
+
+
+'''
+ else:
+ # Exact source mapping prevents a fallback from hiding a broken local package.
+ configuration = f'''
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+ (cls.directory / "NuGet.Config").write_text(configuration, encoding="utf-8")
+ cls.restored = False
+
+ def test_restclient_dependency_requires_fixed_analyzer_and_allows_analyzers(self):
+ self.assertEqual(OPTIONS.restclient_version,
+ self.restclient_manifest.findtext("{*}metadata/{*}version"))
+ groups = self.restclient_manifest.findall("{*}metadata/{*}dependencies/{*}group")
+ self.assertTrue(groups, "RestClient.Net must declare dependencies for its target frameworks")
+ for group in groups:
+ with self.subTest(framework=group.attrib.get("targetFramework")):
+ dependencies = [item for item in group if item.attrib.get("id", "").lower() == "exhaustion"]
+ self.assertEqual(1, len(dependencies), "Each framework must receive Exhaustion")
+ dependency = dependencies[0]
+ self.assertGreaterEqual(
+ version_tuple(dependency.attrib["version"]), MINIMUM_FIXED_VERSION,
+ "RestClient.Net still permits the unfixed Exhaustion 1.0.0 package",
+ )
+ self.assertEqual(version_tuple(OPTIONS.exhaustion_version),
+ version_tuple(dependency.attrib["version"]))
+ excluded = {part.strip().lower() for part in re.split(r"[;,]", dependency.attrib.get("exclude", ""))}
+ self.assertFalse({"all", "analyzers", "buildtransitive"} & excluded,
+ f"RestClient.Net must propagate analyzer assets: {dependency.attrib}")
+
+ def test_analyzer_package_contains_fixed_version_and_compiler_asset(self):
+ self.assertEqual(OPTIONS.exhaustion_version,
+ self.analyzer_manifest.findtext("{*}metadata/{*}version"))
+ self.assertGreaterEqual(version_tuple(OPTIONS.exhaustion_version), MINIMUM_FIXED_VERSION,
+ "A release must publish the analyzer containing the issue #146 fix")
+ self.assertIn("analyzers/dotnet/cs/Exhaustion.dll", self.analyzer_files)
+ self.assertFalse(any(name.startswith("lib/") and name.endswith("Exhaustion.dll")
+ for name in self.analyzer_files), "The analyzer must not become a runtime dependency")
+
+ def restore_consumer(self):
+ if not self.__class__.restored:
+ code, output, _ = run_dotnet([
+ "restore", str(self.project), "--configfile", "NuGet.Config",
+ "--disable-parallel", "-p:NuGetAudit=false", "-p:RestoreIgnoreFailedSources=false",
+ ], self.directory, 120)
+ self.assertEqual(0, code, "The independent consumer must restore successfully:\n" + output)
+ self.__class__.restored = True
+ assets = json.loads((self.directory / "obj/project.assets.json").read_text())
+ self.assertIn(f"RestClient.Net/{OPTIONS.restclient_version}", assets["libraries"])
+ self.assertIn(f"Exhaustion/{OPTIONS.exhaustion_version}", assets["libraries"])
+ framework = assets["project"]["frameworks"][OPTIONS.framework]
+ self.assertEqual(["RestClient.Net"], list(framework["dependencies"]),
+ "The consumer must enable Exhaustion solely by referencing RestClient.Net")
+
+ def build_consumer(self, source):
+ self.restore_consumer()
+ (self.directory / "Consumer.cs").write_text(source, encoding="utf-8")
+ diagnostics_file = self.directory / "diagnostics.sarif"
+ diagnostics_file.unlink(missing_ok=True)
+ code, output, size = run_dotnet([
+ "build", str(self.project), "--no-restore", "--disable-build-servers",
+ "--configuration", "Release", "--verbosity", "minimal", "--nologo",
+ "-t:Rebuild", "-m:1", "-nodeReuse:false", "-p:UseSharedCompilation=false",
+ f"-p:ErrorLog={diagnostics_file}",
+ ], self.directory, 90)
+ self.assertLess(size, 65536, "Compiler output must remain bounded; final excerpt:\n" + output[-4096:])
+ for unexpected in ("AD0001", "CS8032", "CS8785", "OutOfMemoryException", "Stack overflow"):
+ self.assertNotIn(unexpected, output, "The packaged analyzer must load and finish normally:\n" + output)
+ self.assertTrue(diagnostics_file.is_file(), "The compiler did not produce diagnostics:\n" + output)
+ diagnostics = json.loads(diagnostics_file.read_text())
+ results = [result for run in diagnostics["runs"] for result in run.get("results", [])]
+ return code, output, results
+
+ def test_consumer_incomplete_then_complete_then_incomplete_switch(self):
+ hierarchy = '''public abstract record Choice
+{
+ private Choice() { }
+ public sealed record One : Choice;
+ public sealed record Two : Choice;
+}
+public static class Consumer
+{
+ public static int Evaluate(Choice choice) => choice switch
+ {
+ Choice.One => 1,
+ REPLACEMENT
+ };
+}
+'''
+ for complete in (False, True, False):
+ with self.subTest(complete=complete):
+ source = hierarchy.replace("REPLACEMENT", "Choice.Two => 2," if complete else "_ => 0,")
+ code, output, diagnostics = self.build_consumer(source)
+ if complete:
+ self.assertEqual(0, code, output)
+ self.assertEqual([], diagnostics, "A complete switch must compile without diagnostics")
+ else:
+ self.assertNotEqual(0, code, "An incomplete hierarchy compiled successfully: the packaged analyzer never ran.\n" + output)
+ self.assertEqual(["EXHAUSTION001"], [item["ruleId"] for item in diagnostics], output)
+ self.assertEqual("error", diagnostics[0]["level"])
+ self.assertIn("Missing: Two", str(diagnostics[0]["message"]))
+ self.assertIn("Consumer.cs", str(diagnostics[0]["locations"]))
+
+ def test_consumer_issue146_recursive_hierarchy_reports_bounded_diagnostic(self):
+ # Same 25-leaf, 25^3 constructor-product reproducer as BoundedAnalysisRegressionTests.
+ leaves = "\n".join(f"public sealed record Leaf{index} : Expression;" for index in range(24))
+ hierarchy = f'''public abstract record Expression
+{{
+ private Expression() {{ }}
+ {leaves}
+ public sealed record Branch(Expression Left, Expression Middle, Expression Right) : Expression;
+}}
+'''
+ expression = '''public static class Consumer
+{
+ public static Expression StripAlias(Expression value) => value switch
+ {
+ Expression.Branch branch => branch.Left,
+ _ => value,
+ };
+}
+'''
+ statement = '''public static class Consumer
+{
+ public static Expression StripAlias(Expression value)
+ {
+ switch (value)
+ {
+ case Expression.Branch branch: return branch.Left;
+ default: return value;
+ }
+ }
+}
+'''
+ for source in (expression, statement):
+ with self.subTest(switch="expression" if source == expression else "statement"):
+ code, output, diagnostics = self.build_consumer(hierarchy + source)
+ self.assertEqual(0, code, "Issue #146 must produce a controlled warning, without crashing:\n" + output)
+ self.assertEqual(["EXHAUSTION002"], [item["ruleId"] for item in diagnostics],
+ "The installed analyzer must execute the bounded issue #146 analysis:\n" + output)
+ self.assertEqual("warning", diagnostics[0]["level"])
+ self.assertIn("could not be determined", str(diagnostics[0]["message"]))
+ self.assertLess(len(str(diagnostics[0]["message"])), 4096)
+ self.assertIn("Consumer.cs", str(diagnostics[0]["locations"]))
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ source = parser.add_mutually_exclusive_group(required=True)
+ source.add_argument("--packages", type=Path, help="Directory containing both release .nupkg files")
+ source.add_argument("--public-nuget", action="store_true", help="Verify published packages and restore solely from nuget.org")
+ parser.add_argument("--restclient-version", required=True)
+ parser.add_argument("--exhaustion-version", required=True)
+ parser.add_argument("--framework", default="net8.0")
+ OPTIONS, remaining = parser.parse_known_args()
+ with ExitStack() as cleanup:
+ if OPTIONS.public_nuget:
+ OPTIONS.packages = Path(cleanup.enter_context(tempfile.TemporaryDirectory(prefix="published-release-packages-")))
+ for package_id, version in (("RestClient.Net", OPTIONS.restclient_version),
+ ("Exhaustion", OPTIONS.exhaustion_version)):
+ filename = f"{package_id}.{version}.nupkg"
+ url = f"https://api.nuget.org/v3-flatcontainer/{package_id.lower()}/{version.lower()}/{filename.lower()}"
+ with urllib.request.urlopen(url, timeout=60) as response:
+ content = response.read(32 * 1024 * 1024 + 1)
+ if len(content) > 32 * 1024 * 1024:
+ raise AssertionError(f"Unexpectedly large release package: {url}")
+ (OPTIONS.packages / filename).write_bytes(content)
+ else:
+ OPTIONS.packages = OPTIONS.packages.resolve(strict=True)
+ unittest.main(argv=[__file__, *remaining], verbosity=2)