Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
255 changes: 255 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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."
}
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
59 changes: 59 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mailinator-csharp-client/mailinator-csharp-client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" Condition="'$(TargetFramework)' == 'net471'" PrivateAssets="all" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="RestSharp" Version="114.0.0" />
<PackageReference Include="System.Text.Json" Version="10.0.12" />
Expand Down
Loading