From 012453a92bcfbb7c818d42f6aa8dd237e8316cce Mon Sep 17 00:00:00 2001 From: Chris Kenst Date: Mon, 28 Sep 2026 17:01:47 -0700 Subject: [PATCH 1/5] Add CI and NuGet release workflows --- .github/workflows/ci.yml | 41 +++++++++ .github/workflows/release.yml | 166 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 +- README.md | 2 + RELEASING.md | 60 ++++++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3294e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and offline tests + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Restore locked dependencies + run: dotnet restore mailinator-csharp-client.sln --locked-mode + + - name: Build + run: dotnet build mailinator-csharp-client.sln --configuration Release --no-restore + + - name: Run offline unit tests + run: >- + dotnet test + mailinator-csharp-client-unit-tests/mailinator-csharp-client-unit-tests.csproj + --configuration Release + --no-build + --no-restore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5876a8e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish NuGet package and GitHub Release + runs-on: windows-latest + timeout-minutes: 30 + environment: nuget.org + + steps: + - name: Check out tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Validate release version + shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + if ($env:RELEASE_TAG -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') { + throw "Release tag '$env:RELEASE_TAG' must use the form v1.2.3 or v1.2.3-prerelease." + } + + $tagVersion = $Matches.version + [xml]$project = Get-Content 'mailinator-csharp-client/mailinator-csharp-client.csproj' + $projectVersion = [string]$project.Project.PropertyGroup.Version + + if ($projectVersion -ne $tagVersion) { + throw "Tag version '$tagVersion' does not match project version '$projectVersion'." + } + + $escapedVersion = [Regex]::Escape($projectVersion) + $changelog = Get-Content 'CHANGELOG.md' -Raw + if ($changelog -notmatch "(?m)^## \[$escapedVersion\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$") { + throw "CHANGELOG.md must contain a dated release heading for $projectVersion." + } + + "PACKAGE_VERSION=$projectVersion" >> $env:GITHUB_ENV + + - name: Restore locked dependencies + run: dotnet restore mailinator-csharp-client.sln --locked-mode + + - name: Build + run: >- + dotnet build + mailinator-csharp-client.sln + --configuration Release + --no-restore + -p:ContinuousIntegrationBuild=true + + - name: Run offline unit tests + run: >- + dotnet test + mailinator-csharp-client-unit-tests/mailinator-csharp-client-unit-tests.csproj + --configuration Release + --no-build + --no-restore + + - name: Pack + run: >- + dotnet pack + mailinator-csharp-client/mailinator-csharp-client.csproj + --configuration Release + --no-build + --no-restore + --output artifacts + -p:ContinuousIntegrationBuild=true + -p:RepositoryCommit=${{ github.sha }} + + - name: Validate package and prepare release notes + shell: pwsh + run: | + $packages = @(Get-ChildItem 'artifacts/*.nupkg' | Where-Object Name -NotLike '*.snupkg') + if ($packages.Count -ne 1) { + throw "Expected one NuGet package, found $($packages.Count)." + } + + $expectedName = "MailinatorApiClient.$env:PACKAGE_VERSION.nupkg" + if ($packages[0].Name -ne $expectedName) { + throw "Expected package '$expectedName', found '$($packages[0].Name)'." + } + + $lines = @(Get-Content 'CHANGELOG.md') + $heading = $lines | Where-Object { $_ -match "^## \[$([Regex]::Escape($env:PACKAGE_VERSION))\] - " } | Select-Object -First 1 + $start = [Array]::IndexOf($lines, $heading) + if ($start -lt 0) { + throw "Could not find release notes for $env:PACKAGE_VERSION." + } + + $end = $lines.Count + for ($index = $start + 1; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match '^## \[') { + $end = $index + break + } + } + + $notes = ($lines[($start + 1)..($end - 1)] -join [Environment]::NewLine).Trim() + if ([string]::IsNullOrWhiteSpace($notes)) { + throw "Release notes for $env:PACKAGE_VERSION are empty." + } + + $notes | Set-Content 'artifacts/release-notes.md' -Encoding utf8 + "PACKAGE_PATH=$($packages[0].FullName)" >> $env:GITHUB_ENV + + - name: Upload package artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: MailinatorApiClient-${{ env.PACKAGE_VERSION }} + path: ${{ env.PACKAGE_PATH }} + if-no-files-found: error + + - name: Authenticate to NuGet.org + id: nuget_login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish to NuGet.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} + run: >- + dotnet nuget push "$env:PACKAGE_PATH" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + - name: Create GitHub Release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release view "$env:RELEASE_TAG" *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host "GitHub Release $env:RELEASE_TAG already exists; leaving it unchanged." + exit 0 + } + + gh release create "$env:RELEASE_TAG" "$env:PACKAGE_PATH" ` + --verify-tag ` + --title "MailinatorApiClient $env:PACKAGE_VERSION" ` + --notes-file 'artifacts/release-notes.md' + + if ($LASTEXITCODE -ne 0) { + throw "Failed to create GitHub Release $env:RELEASE_TAG." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9702229..0e9e95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on *Keep a Changelog* and this project aims to follow *Semantic Versioning*. -## [2.0.0] - TBD +## [2.0.0] - 2026-09-28 ### Breaking changes diff --git a/README.md b/README.md index 1730fb9..e46484c 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,5 @@ dotnet test mailinator-csharp-client-unit-tests/mailinator-csharp-client-unit-te The separate legacy integration suite calls the live Mailinator API and requires deliberate account configuration. Some tests create or delete remote resources. Read [TESTING.md](TESTING.md) before running it. To compare the SDK request surface with the OpenAPI specification, use the [OpenAPI coverage check](eng/README.md#openapi-coverage-check). + +Maintainers should follow [RELEASING.md](RELEASING.md) to publish NuGet packages and matching GitHub Releases. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8f386fc --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,60 @@ +# Releasing MailinatorApiClient + +NuGet.org is the canonical package registry. Each published package version should also have an annotated Git tag and a GitHub Release using the same version. + +## One-time trusted publishing setup + +The release workflow uses NuGet.org trusted publishing, so it does not store a long-lived API key in GitHub. + +1. In the GitHub repository settings, create an environment named `nuget.org`. Add required reviewers if desired, and add an environment secret named `NUGET_USER` containing the NuGet.org profile name that owns `MailinatorApiClient` (not its email address). +2. On NuGet.org, open the account's **Trusted Publishing** settings and create a GitHub Actions policy with: + - repository owner: `manybrain` + - repository: `mailinator-csharp-client` + - workflow file: `release.yml` + - environment: `nuget.org` + - scope: publishing new versions of existing packages + - package glob: `MailinatorApiClient` +3. Protect tag names matching `v*` in GitHub so only release maintainers can create them. + +The workflow needs to be present on the repository's default branch before its trusted publishing policy can be used for a release. + +## Automated release + +Prepare and merge the release changes before creating the tag: + +1. Set `Version`, `AssemblyVersion`, and `FileVersion` in `mailinator-csharp-client/mailinator-csharp-client.csproj`. +2. Add a dated `## [x.y.z] - YYYY-MM-DD` entry to `CHANGELOG.md`. +3. Confirm CI passes on `master`. +4. From an up-to-date, clean `master`, create and push the annotated tag: + + ```sh + git tag -a v2.0.0 -m "MailinatorApiClient 2.0.0" + git push origin v2.0.0 + ``` + +The tag starts `.github/workflows/release.yml`. The workflow verifies that the tag, project version, and changelog agree; restores locked dependencies; builds the solution; runs only the offline unit tests; creates the package; publishes it to NuGet.org; and creates a GitHub Release with the package attached. + +Do not move or reuse a published version tag. NuGet package versions are immutable. + +## Manual Windows fallback + +Use this only when trusted publishing is unavailable. Start from a clean checkout of the exact commit that will be tagged, with the .NET 8 SDK or later and PowerShell 7 installed. + +```powershell +dotnet restore mailinator-csharp-client.sln --locked-mode +dotnet build mailinator-csharp-client.sln --configuration Release --no-restore +dotnet test mailinator-csharp-client-unit-tests/mailinator-csharp-client-unit-tests.csproj --configuration Release --no-build --no-restore +dotnet pack mailinator-csharp-client/mailinator-csharp-client.csproj --configuration Release --no-build --no-restore --output artifacts +``` + +Inspect `artifacts/MailinatorApiClient.x.y.z.nupkg` before publishing. Create a short-lived NuGet.org API key restricted to pushing new versions of `MailinatorApiClient`, then enter it without placing it in shell history: + +```powershell +$env:NUGET_API_KEY = Read-Host 'NuGet API key' -MaskInput +dotnet nuget push artifacts/MailinatorApiClient.x.y.z.nupkg --source https://api.nuget.org/v3/index.json +Remove-Item Env:NUGET_API_KEY +``` + +After a successful manual push, create and push the annotated tag, then create the corresponding GitHub Release from that tag and attach the same `.nupkg` file. + +The live integration-test project is deliberately excluded from both release paths. It requires a configured Mailinator account and includes tests that mutate remote resources; see `TESTING.md` before running it. From 07e16eb3fcd22b647f91234564faf295e75ce7c2 Mon Sep 17 00:00:00 2001 From: Chris Kenst Date: Mon, 28 Sep 2026 17:15:23 -0700 Subject: [PATCH 2/5] Create release tag before NuGet publish --- .github/workflows/release.yml | 57 ++++++++++++++++++++++++++--------- RELEASING.md | 17 +++++------ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5876a8e..3442ba8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,14 @@ name: Release on: - push: - tags: - - 'v*' + workflow_dispatch: permissions: contents: write id-token: write concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false jobs: @@ -21,7 +19,7 @@ jobs: environment: nuget.org steps: - - name: Check out tagged source + - name: Check out release source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET @@ -34,27 +32,26 @@ jobs: - name: Validate release version shell: pwsh env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_REF: ${{ github.ref }} run: | - if ($env:RELEASE_TAG -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') { - throw "Release tag '$env:RELEASE_TAG' must use the form v1.2.3 or v1.2.3-prerelease." + if ($env:RELEASE_REF -ne 'refs/heads/master') { + throw 'Run releases from the master branch.' } - $tagVersion = $Matches.version [xml]$project = Get-Content 'mailinator-csharp-client/mailinator-csharp-client.csproj' $projectVersion = [string]$project.Project.PropertyGroup.Version - - if ($projectVersion -ne $tagVersion) { - throw "Tag version '$tagVersion' does not match project version '$projectVersion'." + if ($projectVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + throw "Project version '$projectVersion' must use the form 1.2.3 or 1.2.3-prerelease." } $escapedVersion = [Regex]::Escape($projectVersion) $changelog = Get-Content 'CHANGELOG.md' -Raw - if ($changelog -notmatch "(?m)^## \[$escapedVersion\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$") { + if ($changelog -notmatch "(?m)^## \[$escapedVersion\] - [0-9]{4}-[0-9]{2}-[0-9]{2}\r?$") { throw "CHANGELOG.md must contain a dated release heading for $projectVersion." } "PACKAGE_VERSION=$projectVersion" >> $env:GITHUB_ENV + "RELEASE_TAG=v$projectVersion" >> $env:GITHUB_ENV - name: Restore locked dependencies run: dotnet restore mailinator-csharp-client.sln --locked-mode @@ -129,6 +126,38 @@ jobs: path: ${{ env.PACKAGE_PATH }} if-no-files-found: error + - name: Create and push annotated release tag + shell: pwsh + env: + RELEASE_COMMIT: ${{ github.sha }} + run: | + $remoteHead = git ls-remote origin refs/heads/master + if ($LASTEXITCODE -ne 0 -or -not $remoteHead) { + throw 'Could not check the remote master branch.' + } + if (($remoteHead -split '\s+')[0] -ne $env:RELEASE_COMMIT) { + throw 'Master changed during this release run. Start a new release run from the latest commit.' + } + + $existingTag = git ls-remote --exit-code origin "refs/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -eq 0) { + throw "Release tag $env:RELEASE_TAG already exists." + } + if ($LASTEXITCODE -ne 2) { + throw "Could not check whether release tag $env:RELEASE_TAG exists." + } + + $env:GIT_COMMITTER_NAME = 'github-actions[bot]' + $env:GIT_COMMITTER_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' + git tag -a "$env:RELEASE_TAG" -m "MailinatorApiClient $env:PACKAGE_VERSION" "$env:RELEASE_COMMIT" + if ($LASTEXITCODE -ne 0) { + throw "Failed to create release tag $env:RELEASE_TAG." + } + git push origin "refs/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { + throw "Failed to push release tag $env:RELEASE_TAG." + } + - name: Authenticate to NuGet.org id: nuget_login uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 @@ -142,13 +171,11 @@ jobs: run: >- dotnet nuget push "$env:PACKAGE_PATH" --source https://api.nuget.org/v3/index.json - --skip-duplicate - name: Create GitHub Release shell: pwsh env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} run: | gh release view "$env:RELEASE_TAG" *> $null if ($LASTEXITCODE -eq 0) { diff --git a/RELEASING.md b/RELEASING.md index 8f386fc..557fc8b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,25 +14,20 @@ The release workflow uses NuGet.org trusted publishing, so it does not store a l - environment: `nuget.org` - scope: publishing new versions of existing packages - package glob: `MailinatorApiClient` -3. Protect tag names matching `v*` in GitHub so only release maintainers can create them. +3. If tag rules protect names matching `v*`, allow this release workflow to create those tags with its `contents: write` token. The workflow needs to be present on the repository's default branch before its trusted publishing policy can be used for a release. ## Automated release -Prepare and merge the release changes before creating the tag: +Prepare and merge the release changes before starting the workflow: 1. Set `Version`, `AssemblyVersion`, and `FileVersion` in `mailinator-csharp-client/mailinator-csharp-client.csproj`. 2. Add a dated `## [x.y.z] - YYYY-MM-DD` entry to `CHANGELOG.md`. 3. Confirm CI passes on `master`. -4. From an up-to-date, clean `master`, create and push the annotated tag: +4. In GitHub Actions, run the **Release** workflow on `master`. - ```sh - git tag -a v2.0.0 -m "MailinatorApiClient 2.0.0" - git push origin v2.0.0 - ``` - -The tag starts `.github/workflows/release.yml`. The workflow verifies that the tag, project version, and changelog agree; restores locked dependencies; builds the solution; runs only the offline unit tests; creates the package; publishes it to NuGet.org; and creates a GitHub Release with the package attached. +The workflow verifies the project version and changelog; restores locked dependencies; builds the solution; runs only the offline unit tests; and creates the package. It checks that `master` has not advanced and the version tag is unused, then creates and pushes an annotated `vX.Y.Z` tag before publishing to NuGet.org. Finally, it creates a GitHub Release with the package attached. A failed publish leaves the tag in place for diagnosis; do not rerun the workflow with the same version. Do not move or reuse a published version tag. NuGet package versions are immutable. @@ -50,11 +45,13 @@ dotnet pack mailinator-csharp-client/mailinator-csharp-client.csproj --configura Inspect `artifacts/MailinatorApiClient.x.y.z.nupkg` before publishing. Create a short-lived NuGet.org API key restricted to pushing new versions of `MailinatorApiClient`, then enter it without placing it in shell history: ```powershell +git tag -a vX.Y.Z -m "MailinatorApiClient X.Y.Z" +git push origin vX.Y.Z $env:NUGET_API_KEY = Read-Host 'NuGet API key' -MaskInput dotnet nuget push artifacts/MailinatorApiClient.x.y.z.nupkg --source https://api.nuget.org/v3/index.json Remove-Item Env:NUGET_API_KEY ``` -After a successful manual push, create and push the annotated tag, then create the corresponding GitHub Release from that tag and attach the same `.nupkg` file. +Create the tag on the exact commit used to build the package. After publishing, create the corresponding GitHub Release from that tag and attach the same `.nupkg` file. The live integration-test project is deliberately excluded from both release paths. It requires a configured Mailinator account and includes tests that mutate remote resources; see `TESTING.md` before running it. From cb04a4c1fde3f760f027823e2182a387ac09bf3a Mon Sep 17 00:00:00 2001 From: Chris Kenst Date: Mon, 28 Sep 2026 17:23:04 -0700 Subject: [PATCH 3/5] Make release workflow retry-safe --- .github/workflows/release.yml | 66 +++++++++++++++++++++++++---------- RELEASING.md | 4 ++- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3442ba8..6e7d8c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: path: ${{ env.PACKAGE_PATH }} if-no-files-found: error - - name: Create and push annotated release tag + - name: Create or verify annotated release tag shell: pwsh env: RELEASE_COMMIT: ${{ github.sha }} @@ -139,23 +139,39 @@ jobs: throw 'Master changed during this release run. Start a new release run from the latest commit.' } - $existingTag = git ls-remote --exit-code origin "refs/tags/$env:RELEASE_TAG" - if ($LASTEXITCODE -eq 0) { - throw "Release tag $env:RELEASE_TAG already exists." - } - if ($LASTEXITCODE -ne 2) { + $tagRefs = @(git ls-remote origin "refs/tags/$env:RELEASE_TAG" "refs/tags/$env:RELEASE_TAG^{}") + if ($LASTEXITCODE -ne 0) { throw "Could not check whether release tag $env:RELEASE_TAG exists." } - $env:GIT_COMMITTER_NAME = 'github-actions[bot]' - $env:GIT_COMMITTER_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' - git tag -a "$env:RELEASE_TAG" -m "MailinatorApiClient $env:PACKAGE_VERSION" "$env:RELEASE_COMMIT" - if ($LASTEXITCODE -ne 0) { - throw "Failed to create release tag $env:RELEASE_TAG." + if ($tagRefs.Count -gt 0) { + $peeledRefName = "refs/tags/$env:RELEASE_TAG^{}" + $peeledTag = $tagRefs | + Where-Object { ($_ -split '\s+')[1] -eq $peeledRefName } | + Select-Object -First 1 + + if (-not $peeledTag) { + throw "Existing release tag $env:RELEASE_TAG is not annotated." + } + + $existingTagCommit = ($peeledTag -split '\s+')[0] + if ($existingTagCommit -ne $env:RELEASE_COMMIT) { + throw "Existing release tag $env:RELEASE_TAG points to $existingTagCommit instead of $env:RELEASE_COMMIT." + } + + Write-Host "Existing release tag $env:RELEASE_TAG points to the release commit; resuming the release." } - git push origin "refs/tags/$env:RELEASE_TAG" - if ($LASTEXITCODE -ne 0) { - throw "Failed to push release tag $env:RELEASE_TAG." + else { + $env:GIT_COMMITTER_NAME = 'github-actions[bot]' + $env:GIT_COMMITTER_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' + git tag -a "$env:RELEASE_TAG" -m "MailinatorApiClient $env:PACKAGE_VERSION" "$env:RELEASE_COMMIT" + if ($LASTEXITCODE -ne 0) { + throw "Failed to create release tag $env:RELEASE_TAG." + } + git push origin "refs/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { + throw "Failed to push release tag $env:RELEASE_TAG." + } } - name: Authenticate to NuGet.org @@ -171,6 +187,7 @@ jobs: run: >- dotnet nuget push "$env:PACKAGE_PATH" --source https://api.nuget.org/v3/index.json + --skip-duplicate - name: Create GitHub Release shell: pwsh @@ -183,10 +200,23 @@ jobs: exit 0 } - gh release create "$env:RELEASE_TAG" "$env:PACKAGE_PATH" ` - --verify-tag ` - --title "MailinatorApiClient $env:PACKAGE_VERSION" ` - --notes-file 'artifacts/release-notes.md' + $releaseArguments = @( + 'release' + 'create' + $env:RELEASE_TAG + $env:PACKAGE_PATH + '--verify-tag' + '--title' + "MailinatorApiClient $env:PACKAGE_VERSION" + '--notes-file' + 'artifacts/release-notes.md' + ) + if ($env:PACKAGE_VERSION.Contains('-')) { + $releaseArguments += '--prerelease' + $releaseArguments += '--latest=false' + } + + & gh @releaseArguments if ($LASTEXITCODE -ne 0) { throw "Failed to create GitHub Release $env:RELEASE_TAG." diff --git a/RELEASING.md b/RELEASING.md index 557fc8b..0dd97af 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,9 @@ Prepare and merge the release changes before starting the workflow: 3. Confirm CI passes on `master`. 4. In GitHub Actions, run the **Release** workflow on `master`. -The workflow verifies the project version and changelog; restores locked dependencies; builds the solution; runs only the offline unit tests; and creates the package. It checks that `master` has not advanced and the version tag is unused, then creates and pushes an annotated `vX.Y.Z` tag before publishing to NuGet.org. Finally, it creates a GitHub Release with the package attached. A failed publish leaves the tag in place for diagnosis; do not rerun the workflow with the same version. +The workflow verifies the project version and changelog; restores locked dependencies; builds the solution; runs only the offline unit tests; and creates the package. It checks that `master` has not advanced, then creates and pushes an annotated `vX.Y.Z` tag before publishing to NuGet.org. Finally, it creates a GitHub Release with the package attached. Prerelease package versions produce GitHub prereleases and are not marked latest. + +The workflow is safe to rerun after a partial failure. It resumes when the existing annotated tag points to the same release commit, skips a package version that NuGet.org already has, and leaves an existing GitHub Release unchanged. It stops if the tag is lightweight or points to another commit. Do not move or reuse a published version tag. NuGet package versions are immutable. From 25ce8332e24c1f30ed8c6724a87d44fa7fa10892 Mon Sep 17 00:00:00 2001 From: Chris Kenst Date: Mon, 28 Sep 2026 17:35:58 -0700 Subject: [PATCH 4/5] Handle partial release retries safely --- .github/workflows/release.yml | 44 ++++++++++++++++++++++++++++++----- RELEASING.md | 2 +- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e7d8c7..6bd6706 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,7 @@ jobs: } Write-Host "Existing release tag $env:RELEASE_TAG points to the release commit; resuming the release." + 'RELEASE_TAG_REUSED=true' >> $env:GITHUB_ENV } else { $env:GIT_COMMITTER_NAME = 'github-actions[bot]' @@ -172,6 +173,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Failed to push release tag $env:RELEASE_TAG." } + 'RELEASE_TAG_REUSED=false' >> $env:GITHUB_ENV } - name: Authenticate to NuGet.org @@ -184,19 +186,49 @@ jobs: shell: pwsh env: NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} - run: >- - dotnet nuget push "$env:PACKAGE_PATH" - --source https://api.nuget.org/v3/index.json - --skip-duplicate + run: | + $pushArguments = @('nuget', 'push', $env:PACKAGE_PATH, '--source', 'https://api.nuget.org/v3/index.json') + if ($env:RELEASE_TAG_REUSED -eq 'true') { + $pushArguments += '--skip-duplicate' + } + + & dotnet @pushArguments + if ($LASTEXITCODE -ne 0) { + throw "Failed to publish package $env:PACKAGE_PATH to NuGet.org." + } - name: Create GitHub Release shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | - gh release view "$env:RELEASE_TAG" *> $null + $releaseJson = gh release view "$env:RELEASE_TAG" --json isDraft,assets 2>$null if ($LASTEXITCODE -eq 0) { - Write-Host "GitHub Release $env:RELEASE_TAG already exists; leaving it unchanged." + $release = $releaseJson | ConvertFrom-Json + $packageName = [IO.Path]::GetFileName($env:PACKAGE_PATH) + $asset = $release.assets | Where-Object name -eq $packageName | Select-Object -First 1 + + if ($release.isDraft) { + if (-not $asset) { + gh release upload "$env:RELEASE_TAG" "$env:PACKAGE_PATH" + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload package to draft GitHub Release $env:RELEASE_TAG." + } + } + + gh release edit "$env:RELEASE_TAG" --draft=false + if ($LASTEXITCODE -ne 0) { + throw "Failed to publish draft GitHub Release $env:RELEASE_TAG." + } + Write-Host "Completed draft GitHub Release $env:RELEASE_TAG." + exit 0 + } + + if (-not $asset) { + throw "GitHub Release $env:RELEASE_TAG exists without package asset $packageName." + } + + Write-Host "GitHub Release $env:RELEASE_TAG already exists with its package asset; leaving it unchanged." exit 0 } diff --git a/RELEASING.md b/RELEASING.md index 0dd97af..00fc18e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,7 +29,7 @@ Prepare and merge the release changes before starting the workflow: The workflow verifies the project version and changelog; restores locked dependencies; builds the solution; runs only the offline unit tests; and creates the package. It checks that `master` has not advanced, then creates and pushes an annotated `vX.Y.Z` tag before publishing to NuGet.org. Finally, it creates a GitHub Release with the package attached. Prerelease package versions produce GitHub prereleases and are not marked latest. -The workflow is safe to rerun after a partial failure. It resumes when the existing annotated tag points to the same release commit, skips a package version that NuGet.org already has, and leaves an existing GitHub Release unchanged. It stops if the tag is lightweight or points to another commit. +The workflow can resume after a partial failure. It resumes when the existing annotated tag points to the same release commit, skips a package version that NuGet.org already has only on a retry, and completes an unfinished draft GitHub Release. It leaves a published GitHub Release unchanged only when the package asset is present. It stops if the tag is lightweight, points to another commit, or a published release is missing its package asset. Do not move or reuse a published version tag. NuGet package versions are immutable. From 67e25ac3058b9bf640375f256524961746dc2dba Mon Sep 17 00:00:00 2001 From: Chris Kenst Date: Mon, 28 Sep 2026 17:51:51 -0700 Subject: [PATCH 5/5] Declare net471 reference assemblies for locked restore --- mailinator-csharp-client/mailinator-csharp-client.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/mailinator-csharp-client/mailinator-csharp-client.csproj b/mailinator-csharp-client/mailinator-csharp-client.csproj index 65cadde..7f81f54 100644 --- a/mailinator-csharp-client/mailinator-csharp-client.csproj +++ b/mailinator-csharp-client/mailinator-csharp-client.csproj @@ -22,6 +22,7 @@ +