Skip to content

Commit 0b31a12

Browse files
Publish and verify Exhaustion before releasing RestClient.Net
# TLDR; Publish and verify Exhaustion before releasing RestClient.Net. Prepare Exhaustion 1.0.1 and RestClient.Net 7.3.1 so consumers receive the bounded analyzer fix. # Details - Share `ExhaustionVersion` across the analyzer and package references, and retain transitive analyzer assets in every RestClient.Net target framework. - Build both release packages from the checked-out source with isolated restore caches and reproducible paths. Apply manual or tag versions before building instead of reading stale repository variables. - Test the actual packages before publication, publish Exhaustion first, and wait for its matching DLL and NuGet index entry before publishing RestClient.Net. Reject an existing version whose binaries or dependencies differ from this build. - Verify public NuGet downloads and run fresh consumer tests after publication. Serialize the two release workflows and apply the same validation to standalone analyzer releases. - Run release orchestration and package-consumer regressions in ordinary CI, including unpublished analyzer versions. # How Do The Tests Prove The Changes Work? The package regression suite fails against published RestClient.Net 7.3.0 / Exhaustion 1.0.0: the dependency remains at 1.0.0 and issue #146 emits over 1 MB of diagnostics instead of the bounded `EXHAUSTION002` diagnostic. It passes against the new packages on .NET 8 and 9. Each consumer references only RestClient.Net and exercises incomplete → complete → incomplete switch edits, plus both recursive switch forms, with heap and timeout limits. Twenty-nine orchestration tests cover dependency-first publication, stale same-version binaries, all target-framework dependency groups, indexing delays, HTTP failures, deadlines, and malformed versions. Full local CI passes: 773 .NET test executions, zero failures or skips, 100% mutation score, formatting and warning-as-error builds. All workflows pass actionlint.
1 parent 020ecab commit 0b31a12

13 files changed

Lines changed: 1229 additions & 59 deletions

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
- name: Checkout code
2525
uses: actions/checkout@v4
2626

27+
- name: Isolate NuGet cache
28+
run: echo "NUGET_PACKAGES=$RUNNER_TEMP/ci-nuget" >> "$GITHUB_ENV"
29+
2730
- name: Setup .NET
2831
uses: actions/setup-dotnet@v4
2932
with:
@@ -35,7 +38,9 @@ jobs:
3538
run: dotnet tool restore
3639

3740
- name: Restore dependencies
38-
run: dotnet restore RestClient.sln
41+
run: |
42+
python3 scripts/release.py pack-analyzer --output .artifacts/packages
43+
dotnet restore RestClient.sln --source "$PWD/.artifacts/packages" --source https://api.nuget.org/v3/index.json
3944
4045
- name: Check code formatting with CSharpier
4146
run: dotnet csharpier --check .
@@ -61,6 +66,24 @@ jobs:
6166
- name: Run F# tests outside the solution
6267
run: dotnet test RestClient.Net.FsTest/RestClient.Net.FsTest.fsproj --configuration Release --verbosity normal
6368

69+
- name: Test release ordering and artifact verification
70+
run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
71+
72+
- name: Build and test release packages before publication
73+
id: release_versions
74+
run: |
75+
python3 scripts/release.py versions >> "$GITHUB_OUTPUT"
76+
python3 scripts/release.py pack --output .artifacts/release
77+
78+
- name: Verify fixed analyzer through package consumers
79+
env:
80+
CLIENT_VERSION: ${{ steps.release_versions.outputs.client_version }}
81+
ANALYZER_VERSION: ${{ steps.release_versions.outputs.analyzer_version }}
82+
run: |
83+
for framework in net8.0 net9.0; do
84+
python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
85+
done
86+
6487
- name: Cleanup Docker containers
6588
if: always()
6689
run: |

‎.github/workflows/publish-exhaustion.yml‎

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,50 +7,76 @@ on:
77
workflow_dispatch:
88
inputs:
99
version:
10-
description: 'Version number (e.g., 1.0.0)'
10+
description: 'Exhaustion version (e.g., 1.0.1)'
1111
required: true
1212
type: string
1313

14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: nuget-release-${{ github.repository }}
19+
cancel-in-progress: false
20+
1421
jobs:
1522
publish:
1623
runs-on: ubuntu-latest
24+
timeout-minutes: 20
25+
env:
26+
DOTNET_PROCESSOR_COUNT: '2'
1727

1828
steps:
19-
- name: Checkout code
29+
- name: Checkout release source
2030
uses: actions/checkout@v4
2131

32+
- name: Isolate NuGet cache
33+
run: echo "NUGET_PACKAGES=$RUNNER_TEMP/release-nuget" >> "$GITHUB_ENV"
34+
35+
- name: Resolve and validate release versions
36+
id: versions
37+
env:
38+
REQUESTED_VERSION: ${{ inputs.version || github.ref_name }}
39+
run: python3 scripts/release.py versions --analyzer-version "$REQUESTED_VERSION" >> "$GITHUB_OUTPUT"
40+
2241
- name: Setup .NET
2342
uses: actions/setup-dotnet@v4
2443
with:
25-
dotnet-version: '8.0.x'
26-
27-
- name: Restore dependencies
28-
run: dotnet restore Exhaustion/Exhaustion.csproj
44+
dotnet-version: |
45+
8.0.x
46+
9.0.x
2947
30-
- name: Build
31-
run: dotnet build Exhaustion/Exhaustion.csproj --configuration Release --no-restore
32-
33-
- name: Set version from tag
34-
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
35-
run: |
36-
VERSION=${GITHUB_REF#refs/tags/exhaustion-v}
37-
echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV
48+
- name: Test release ordering and artifact verification
49+
run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
3850

39-
- name: Set version from input
40-
if: github.event_name == 'workflow_dispatch'
41-
run: |
42-
echo "PACKAGE_VERSION=${{ vars.PACKAGE_VERSION }}" >> $GITHUB_ENV
51+
- name: Build analyzer and local consumer package
52+
env:
53+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
54+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
55+
run: python3 scripts/release.py pack --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
4356

44-
- name: Pack
45-
run: dotnet pack Exhaustion/Exhaustion.csproj --configuration Release --no-build --output ./packages /p:Version=${{ env.PACKAGE_VERSION }}
57+
- name: Run analyzer regressions
58+
env:
59+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
60+
run: dotnet test Exhaustion.Tests/Exhaustion.Tests.csproj --configuration Release --verbosity normal -p:ExhaustionVersion="$ANALYZER_VERSION"
4661

47-
- name: Publish to NuGet
48-
run: dotnet nuget push ./packages/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
62+
- name: Verify analyzer through the consumer package
4963
env:
50-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
64+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
65+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
66+
run: |
67+
for framework in net8.0 net9.0; do
68+
python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
69+
done
5170
52-
- name: Upload package artifact
71+
- name: Upload verified analyzer package
5372
uses: actions/upload-artifact@v4
5473
with:
5574
name: exhaustion-nuget-package
56-
path: ./packages/*.nupkg
75+
path: .artifacts/release/Exhaustion.*.nupkg
76+
if-no-files-found: error
77+
78+
- name: Publish and verify analyzer
79+
env:
80+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
81+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
82+
run: python3 scripts/release.py publish-analyzer --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Publish RestClient.Net to NuGet
1+
name: Release RestClient.Net and Exhaustion
22

33
on:
44
push:
@@ -7,50 +7,86 @@ on:
77
workflow_dispatch:
88
inputs:
99
version:
10-
description: 'Version number (e.g., 6.0.0)'
10+
description: 'RestClient.Net version (e.g., 7.3.1); ExhaustionVersion comes from Directory.Build.props'
1111
required: true
1212
type: string
1313

14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: nuget-release-${{ github.repository }}
19+
cancel-in-progress: false
20+
1421
jobs:
1522
publish:
1623
runs-on: ubuntu-latest
24+
timeout-minutes: 30
25+
env:
26+
DOTNET_PROCESSOR_COUNT: '2'
1727

1828
steps:
19-
- name: Checkout code
29+
- name: Checkout release source
2030
uses: actions/checkout@v4
2131

32+
- name: Isolate NuGet cache
33+
run: echo "NUGET_PACKAGES=$RUNNER_TEMP/release-nuget" >> "$GITHUB_ENV"
34+
35+
- name: Resolve and validate release versions
36+
id: versions
37+
env:
38+
REQUESTED_VERSION: ${{ inputs.version || github.ref_name }}
39+
run: python3 scripts/release.py versions --client-version "$REQUESTED_VERSION" >> "$GITHUB_OUTPUT"
40+
2241
- name: Setup .NET
2342
uses: actions/setup-dotnet@v4
2443
with:
25-
dotnet-version: '8.0.x'
44+
dotnet-version: |
45+
8.0.x
46+
9.0.x
2647
27-
- name: Restore dependencies
28-
run: dotnet restore RestClient.Net/RestClient.Net.csproj
48+
- name: Test release ordering and artifact verification
49+
run: python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
2950

30-
- name: Build
31-
run: dotnet build RestClient.Net/RestClient.Net.csproj --configuration Release --no-restore
51+
- name: Build both release packages from this commit
52+
env:
53+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
54+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
55+
run: python3 scripts/release.py pack --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
3256

33-
- name: Set version from tag
34-
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
35-
run: |
36-
VERSION=${GITHUB_REF#refs/tags/restclient-v}
37-
echo "REST_CLIENT_DOTNET_VERSION=$VERSION" >> $GITHUB_ENV
57+
- name: Run analyzer regressions
58+
run: dotnet test Exhaustion.Tests/Exhaustion.Tests.csproj --configuration Release --verbosity normal
3859

39-
- name: Set version from input
40-
if: github.event_name == 'workflow_dispatch'
60+
- name: Verify actual packages in isolated consumers
61+
env:
62+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
63+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
4164
run: |
42-
echo "REST_CLIENT_DOTNET_VERSION=${{ vars.REST_CLIENT_DOTNET_VERSION }}" >> $GITHUB_ENV
65+
for framework in net8.0 net9.0; do
66+
python3 scripts/tests/test_release_packages.py --packages .artifacts/release --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
67+
done
4368
44-
- name: Pack
45-
run: dotnet pack RestClient.Net/RestClient.Net.csproj --configuration Release --no-build --output ./packages /p:Version=${{ env.REST_CLIENT_DOTNET_VERSION }}
69+
- name: Upload verified release packages
70+
uses: actions/upload-artifact@v4
71+
with:
72+
name: restclient-and-exhaustion-packages
73+
path: |
74+
.artifacts/release/*.nupkg
75+
.artifacts/release/*.snupkg
76+
if-no-files-found: error
4677

47-
- name: Publish to NuGet
48-
run: dotnet nuget push ./packages/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
78+
- name: Publish and verify Exhaustion before RestClient.Net
4979
env:
5080
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
81+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
82+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
83+
run: python3 scripts/release.py publish --client-version "$CLIENT_VERSION" --analyzer-version "$ANALYZER_VERSION" --output .artifacts/release
5184

52-
- name: Upload package artifact
53-
uses: actions/upload-artifact@v4
54-
with:
55-
name: restclient-nuget-package
56-
path: ./packages/*.nupkg
85+
- name: Verify published packages from NuGet in fresh consumers
86+
env:
87+
CLIENT_VERSION: ${{ steps.versions.outputs.client_version }}
88+
ANALYZER_VERSION: ${{ steps.versions.outputs.analyzer_version }}
89+
run: |
90+
for framework in net8.0 net9.0; do
91+
python3 scripts/tests/test_release_packages.py --public-nuget --restclient-version "$CLIENT_VERSION" --exhaustion-version "$ANALYZER_VERSION" --framework "$framework"
92+
done

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ coverage.cobertura.xml
1515
final-check/
1616
test-output-final
1717
nupkgs/
18+
.artifacts/
19+
__pycache__/
1820

1921
# Website
2022
Website/node_modules/

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The existing dotnet commands are the local equivalents of the Makefile commands
1313

1414
| Purpose | Command |
1515
| --- | --- |
16-
| Setup | `dotnet tool restore` and `dotnet restore RestClient.sln` |
16+
| 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` |
1717
| Build | `dotnet build RestClient.sln --configuration Release --no-restore /warnaserror` |
1818
| Analysis | `dotnet build RestClient.sln --configuration Release --no-restore /p:RunAnalyzers=true /p:TreatWarningsAsErrors=true` |
1919
| 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
3131

3232
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.
3333

34+
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.
35+
3436
## Integration-test isolation
3537

3638
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.

‎Directory.Build.props‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<NoWarn>$(NoWarn);CA2234;IDE0290;SA1513;SA1010;SA1400;SA1122;SA1124;SA1516;SA1114;SA1005;SA1124</NoWarn>
1212
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1313
<LangVersion>latest</LangVersion>
14+
<ExhaustionVersion>1.0.1</ExhaustionVersion>
1415
</PropertyGroup>
1516
<ItemGroup>
1617
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
@@ -21,9 +22,9 @@
2122
<PrivateAssets>all</PrivateAssets>
2223
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
2324
</PackageReference>
24-
<PackageReference Include="Exhaustion" Version="1.0.0" Condition="'$(MSBuildProjectName)' != 'Exhaustion' AND '$(MSBuildProjectName)' != 'Exhaustion.Tests' AND '$(MSBuildProjectName)' != 'RestClient.Net' AND '$(MSBuildProjectName)' != 'Buh'">
25+
<PackageReference Include="Exhaustion" Version="$(ExhaustionVersion)" Condition="'$(MSBuildProjectName)' != 'Exhaustion' AND '$(MSBuildProjectName)' != 'Exhaustion.Tests' AND '$(MSBuildProjectName)' != 'RestClient.Net' AND '$(MSBuildProjectName)' != 'Buh'">
2526
<PrivateAssets>all</PrivateAssets>
2627
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
2728
</PackageReference>
2829
</ItemGroup>
29-
</Project>
30+
</Project>

‎Exhaustion/Exhaustion.csproj‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
<!-- NuGet Package Metadata -->
1010
<PackageId>Exhaustion</PackageId>
11-
<Version>1.0.0</Version>
11+
<Version>$(ExhaustionVersion)</Version>
12+
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
1213
<Authors>RestClient.Net Contributors</Authors>
1314
<Company>RestClient.Net</Company>
1415
<Description>Roslyn analyzer that enforces exhaustive pattern matching for closed type hierarchies in C#</Description>

‎RestClient.Net/RestClient.Net.csproj‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<TargetFrameworks>net8.0;net9.0;netstandard2.1</TargetFrameworks>
44
<IsPackable>true</IsPackable>
55
<PackageId>RestClient.Net</PackageId>
6-
<Version>7.1.2</Version>
6+
<Version>7.3.1</Version>
77
<Authors>Christian Findlay</Authors>
88
<Company>Christian Findlay</Company>
99
<Description>The safest way to make REST calls in C#. Functional HTTP client library with Result types, exhaustiveness checking, and zero exceptions.</Description>
@@ -22,13 +22,13 @@
2222
<PrivateAssets></PrivateAssets>
2323
<IncludeAssets>all</IncludeAssets>
2424
</PackageReference>
25-
<PackageReference Include="Exhaustion" Version="1.0.0">
26-
<PrivateAssets></PrivateAssets>
25+
<PackageReference Include="Exhaustion" Version="$(ExhaustionVersion)">
26+
<PrivateAssets>none</PrivateAssets>
2727
<IncludeAssets>all</IncludeAssets>
2828
</PackageReference>
2929
<PackageReference Include="Outcome" Version="1.0.0" />
3030
</ItemGroup>
3131
<ItemGroup>
3232
<None Include="..\README.md" Pack="true" PackagePath="\" />
3333
</ItemGroup>
34-
</Project>
34+
</Project>

‎scripts/README.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Verified package releases
2+
3+
The `Release RestClient.Net and Exhaustion` GitHub Action accepts a RestClient.Net
4+
version or a `restclient-v*` tag. It publishes both packages from the checked-out
5+
commit. It uses the requested version directly, never a repository version variable.
6+
7+
`ExhaustionVersion` in `Directory.Build.props` controls the analyzer package version
8+
and the dependency in every RestClient.Net target framework. Increment it when
9+
changing the analyzer. RestClient.Net's project version records the next client
10+
release; a manual release input overrides it consistently during restore, build,
11+
and pack.
12+
13+
Before publishing, the action runs analyzer regressions and installs the packed
14+
RestClient.Net into separate consumers with fresh NuGet caches. These consumers
15+
reference only RestClient.Net: incomplete switches must report `EXHAUSTION001`,
16+
complete switches must compile, and the issue #146 hierarchy must finish with the
17+
bounded `EXHAUSTION002` diagnostic. Consumer processes have time and heap limits.
18+
19+
The action publishes Exhaustion first and waits until its exact analyzer DLL is
20+
downloadable and its version is indexed. Only then can it publish RestClient.Net.
21+
It verifies both published packages and repeats the consumer checks using only
22+
nuget.org. The standalone Exhaustion action uses the same packaging and verification
23+
helpers. Both publishers share a concurrency group.
24+
25+
NuGet versions are immutable. A retry can reuse an existing version only if its
26+
DLLs, package identity, and dependencies match this build. A mismatch fails the
27+
release; increment the affected package version instead of accepting an older DLL.
28+
NuGet's signing changes are excluded from the binary comparison.
29+
30+
## Local verification
31+
32+
```sh
33+
python3 scripts/release.py pack --output .artifacts/release
34+
python3 -m unittest discover -s scripts/tests -p test_release_orchestration.py -v
35+
python3 scripts/tests/test_release_packages.py --packages .artifacts/release \
36+
--restclient-version 7.3.1 --exhaustion-version 1.0.1
37+
```
38+
39+
Packaging uses a new restore cache and separate build output so an existing local
40+
package cannot hide a stale analyzer. CI bootstraps the unpublished analyzer into
41+
`.artifacts/packages` before restoring the solution, allowing the complete suite to
42+
run before a new analyzer version exists on NuGet. These verification commands do
43+
not publish packages.

0 commit comments

Comments
 (0)