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)