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..6bd6706 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,255 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: write + id-token: write + +concurrency: + group: release + 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 release 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_REF: ${{ github.ref }} + run: | + if ($env:RELEASE_REF -ne 'refs/heads/master') { + throw 'Run releases from the master branch.' + } + + [xml]$project = Get-Content 'mailinator-csharp-client/mailinator-csharp-client.csproj' + $projectVersion = [string]$project.Project.PropertyGroup.Version + 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}\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 + + - 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: Create or verify 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.' + } + + $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." + } + + 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." + 'RELEASE_TAG_REUSED=true' >> $env:GITHUB_ENV + } + 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." + } + 'RELEASE_TAG_REUSED=false' >> $env:GITHUB_ENV + } + + - 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: | + $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: | + $releaseJson = gh release view "$env:RELEASE_TAG" --json isDraft,assets 2>$null + if ($LASTEXITCODE -eq 0) { + $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 + } + + $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/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..00fc18e --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,59 @@ +# 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. 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 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. 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, 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 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. + +## 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 +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 +``` + +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. 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 @@ +