From 5462b726189396e5841437b2707941b4681b9a03 Mon Sep 17 00:00:00 2001 From: Ran Trifon Date: Fri, 10 Jul 2026 09:57:21 +0300 Subject: [PATCH] Automate NuGet and GitHub releases --- .github/workflows/ci.yml | 52 +++-- .github/workflows/release.yml | 199 ++++++++++++++++++ CHANGELOG.md | 9 +- docs/packaging.md | 18 ++ docs/releases.md | 46 +++- scripts/get-release-notes.ps1 | 52 +++++ scripts/validate-docs.ps1 | 5 + .../SharedMemoryStore.csproj | 3 + .../PackageContractTests.cs | 3 + 9 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/get-release-notes.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f64f9b2..b95c6a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: name: Validate (${{ matrix.os }}) @@ -18,29 +22,53 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: 10.0.x - - run: dotnet restore SharedMemoryStore.slnx - - run: dotnet build SharedMemoryStore.slnx -c Release --no-restore - - run: dotnet format SharedMemoryStore.slnx --verify-no-changes --no-restore - - run: dotnet test SharedMemoryStore.slnx -c Release --no-build - - shell: pwsh + + - name: Restore + run: dotnet restore SharedMemoryStore.slnx + + - name: Build + run: dotnet build SharedMemoryStore.slnx -c Release --no-restore + + - name: Verify formatting + run: dotnet format SharedMemoryStore.slnx --verify-no-changes --no-restore + + - name: Test + run: dotnet test SharedMemoryStore.slnx -c Release --no-build + + - name: Validate documentation + shell: pwsh run: ./scripts/validate-docs.ps1 - - shell: pwsh + + - name: Validate package consumption + shell: pwsh run: ./scripts/validate-package-consumption.ps1 -Configuration Release - - run: dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj -c Release --no-build -o artifacts/package + + - name: Pack + run: dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj -c Release --no-build -o artifacts/package docker: name: Docker sharing runs-on: ubuntu-latest timeout-minutes: 30 + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: 10.0.x - - shell: pwsh + + - name: Validate Docker sharing + shell: pwsh run: ./scripts/validate-docker-shared-memory.ps1 -Configuration Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cd95f13 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,199 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Version to publish; must match the project (for example, 1.0.1) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + validate: + name: Validate and package + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} + + steps: + - name: Check out source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 10.0.x + + - name: Verify release version and target + id: version + shell: pwsh + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + if ($env:GITHUB_REF -ne "refs/heads/main") { + throw "Releases must run from the main branch, not '$env:GITHUB_REF'." + } + + $requested = $env:REQUESTED_VERSION.Trim() + if ($requested -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$') { + throw "'$requested' is not a supported semantic version." + } + + [xml]$project = Get-Content -Raw -LiteralPath "src/SharedMemoryStore/SharedMemoryStore.csproj" + $projectVersion = [string]( + $project.Project.PropertyGroup | + Where-Object { $_.Version } | + Select-Object -First 1 + ).Version + if ($projectVersion -ne $requested) { + throw "Requested version '$requested' does not match project version '$projectVersion'." + } + + git ls-remote --exit-code --tags origin "refs/tags/v$requested" *> $null + if ($LASTEXITCODE -eq 0) { + throw "Tag 'v$requested' already exists. Versions and tags are immutable." + } + if ($LASTEXITCODE -ne 2) { + throw "Could not verify whether tag 'v$requested' exists (git exit code $LASTEXITCODE)." + } + + $published = Invoke-RestMethod ` + -Uri "https://api.nuget.org/v3-flatcontainer/sharedmemorystore/index.json" ` + -Headers @{ "User-Agent" = "SharedMemoryStore-release-workflow" } + if ($published.versions -contains $requested.ToLowerInvariant()) { + throw "SharedMemoryStore $requested is already published on NuGet.org. Package versions are immutable." + } + + "value=$requested" >> $env:GITHUB_OUTPUT + + - name: Run full release validation + shell: pwsh + run: ./scripts/validate-cross-platform.ps1 + + - name: Create release packages and notes + shell: pwsh + env: + RELEASE_VERSION: ${{ steps.version.outputs.value }} + run: | + dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj ` + -c Release ` + --no-build ` + -o artifacts/release + if ($LASTEXITCODE -ne 0) { + throw "dotnet pack failed with exit code $LASTEXITCODE." + } + + $package = "artifacts/release/SharedMemoryStore.$env:RELEASE_VERSION.nupkg" + $symbols = "artifacts/release/SharedMemoryStore.$env:RELEASE_VERSION.snupkg" + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "Expected package was not created: $package" + } + if (-not (Test-Path -LiteralPath $symbols -PathType Leaf)) { + throw "Expected symbol package was not created: $symbols" + } + + ./scripts/get-release-notes.ps1 ` + -Version $env:RELEASE_VERSION ` + -OutputPath artifacts/release/release-notes.md + + - name: Preserve release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nuget-release-${{ steps.version.outputs.value }} + path: | + artifacts/release/SharedMemoryStore.${{ steps.version.outputs.value }}.nupkg + artifacts/release/SharedMemoryStore.${{ steps.version.outputs.value }}.snupkg + artifacts/release/release-notes.md + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish NuGet and GitHub release + needs: validate + runs-on: ubuntu-latest + environment: + name: release + url: https://www.nuget.org/packages/SharedMemoryStore/${{ needs.validate.outputs.version }} + permissions: + contents: write + id-token: write + + steps: + - name: Download release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nuget-release-${{ needs.validate.outputs.version }} + path: artifacts/release + + - name: Exchange GitHub identity for a temporary NuGet API key + id: nuget + uses: NuGet/login@ebc737b6fc418a6ca0073cf116ec8dc156d8b81e # v1 + with: + user: rantri + + - name: Create draft GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + $tag = "v$env:RELEASE_VERSION" + $arguments = @( + "release", "create", $tag, + "artifacts/release/SharedMemoryStore.$env:RELEASE_VERSION.nupkg", + "artifacts/release/SharedMemoryStore.$env:RELEASE_VERSION.snupkg", + "--repo", $env:GITHUB_REPOSITORY, + "--target", $env:GITHUB_SHA, + "--title", $tag, + "--notes-file", "artifacts/release/release-notes.md", + "--draft" + ) + if ($env:RELEASE_VERSION.Contains("-")) { + $arguments += "--prerelease" + } + + gh @arguments + if ($LASTEXITCODE -ne 0) { + throw "Creating the draft GitHub release failed with exit code $LASTEXITCODE." + } + + - name: Publish package and symbols to NuGet.org + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget.outputs.NUGET_API_KEY }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + dotnet nuget push ` + "artifacts/release/SharedMemoryStore.$env:RELEASE_VERSION.nupkg" ` + --api-key $env:NUGET_API_KEY ` + --source https://api.nuget.org/v3/index.json + if ($LASTEXITCODE -ne 0) { + throw "NuGet publishing failed with exit code $LASTEXITCODE. The GitHub release remains a draft." + } + + - name: Publish GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + $arguments = @( + "release", "edit", "v$env:RELEASE_VERSION", + "--repo", $env:GITHUB_REPOSITORY, + "--draft=false" + ) + if (-not $env:RELEASE_VERSION.Contains("-")) { + $arguments += "--latest" + } + + gh @arguments + if ($LASTEXITCODE -ne 0) { + throw "NuGet publishing succeeded, but publishing the draft GitHub release failed. Publish the existing draft manually." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index d560470..12dfd34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,14 @@ All notable package and documentation changes are recorded in reverse chronological order. -## 1.0.1 - 2026-07-09 +## 1.0.1 - 2026-07-10 + +### Packaging + +- Added Linux and Windows GitHub Actions validation and a manually triggered, + trusted-publishing release workflow for NuGet.org and GitHub Releases. +- Added portable `.snupkg` symbols to improve package debugging without growing + the primary package. ### Fixed diff --git a/docs/packaging.md b/docs/packaging.md index 9fe435d..3c0d9b4 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -14,10 +14,12 @@ and targets `net10.0`. Runtime dependencies are limited to the .NET BCL. | `Description` | `A bounded named shared-memory key-value store for opaque binary values.` | | `PackageTags` | `shared-memory;memory-mapped-file;zero-copy;linux;windows;docker;library` | | `PackageLicenseExpression` | `MIT` | +| `PackageProjectUrl` | `https://github.com/rantri/SharedMemoryStore` | | `PackageReadmeFile` | `README.md` | | `PackageReleaseNotes` | `Linux, Windows, and same-host Docker support hardening: fixes bounded waits, crash-safe ownership and index maintenance, private Linux resource permissions, layout overflow validation, and cleanup reliability while preserving the 1.0.0 public API and layout.` | | `RepositoryType` | `git` | | `RepositoryUrl` | `https://github.com/rantri/SharedMemoryStore` | +| `SymbolPackageFormat` | `snupkg` | The package project packs the root [README.md](../README.md) at the package root so NuGet consumers see the same package purpose, status, first-use @@ -32,6 +34,22 @@ dotnet build SharedMemoryStore.slnx -c Release dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj -c Release -o artifacts/package ``` +Packing produces both `SharedMemoryStore..nupkg` and the portable-symbol +package `SharedMemoryStore..snupkg`. NuGet.org publishes the symbol +package to its symbol server alongside the primary package. + +## Automated Publication + +The [CI workflow](../.github/workflows/ci.yml) validates Linux and Windows on +pull requests and pushes to `main`. The manually triggered +[release workflow](../.github/workflows/release.yml) performs the full release +validation, including Docker, verifies that the version is unused, creates the +package and symbols, publishes them to NuGet.org with trusted publishing, and +creates the matching GitHub release and `v` tag. + +The one-time trusted-publishing policy and the exact release procedure are +documented in [Release preparation](releases.md). + ## Clean Consumer Validation ```powershell diff --git a/docs/releases.md b/docs/releases.md index d112adb..41fc258 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -5,6 +5,45 @@ written for the current `1.0.1` package and should be updated when package metadata, public API behavior, compatibility scope, support policy, security reporting, documentation scope, or sample behavior changes. +## Automated Release Process + +Releases are deliberately started by a maintainer and automated after that +point. The [release workflow](../.github/workflows/release.yml) validates the +version and release target, runs the full Linux and Docker release suite, packs +the primary and symbol packages, creates a draft GitHub release, publishes to +NuGet.org, and then publishes the GitHub release. CI separately validates the +same commit on Linux and Windows through +[ci.yml](../.github/workflows/ci.yml). + +Configure NuGet.org trusted publishing once before the first automated release: + +1. Sign in to NuGet.org as the `rantri` owner and open **Trusted Publishing**. +2. Add a GitHub policy with owner `rantri`, repository + `SharedMemoryStore`, workflow file `release.yml`, and environment `release`. +3. In GitHub, confirm the `release` environment exists. An optional required + reviewer adds a second approval before publication. +4. Merge all release changes to `main` and confirm the CI workflow succeeds. + +To publish a release: + +1. Set `` and `PackageReleaseNotes` in the package project, then align + README, packaging documentation, and `CHANGELOG.md`. +2. Complete the compatibility and validation review below. +3. On GitHub, open **Actions**, choose **Release**, select **Run workflow** on + `main`, enter the exact version without a `v` prefix, and run it. +4. Confirm the workflow publishes `SharedMemoryStore..nupkg` and + `.snupkg`, creates tag `v`, and publishes the GitHub release. +5. Allow time for NuGet.org validation and indexing, then install the exact + published version in a clean consumer. + +Do not create the tag or GitHub release first; the workflow owns both. NuGet +package versions are immutable, so a failed release must be diagnosed rather +than retried under the same version with different contents. If NuGet publishing +fails, the workflow intentionally leaves the GitHub release as a draft. If the +package is still absent from NuGet.org, delete that draft and its tag before a +retry. If NuGet.org already has the version, keep the tag and publish the +existing draft after verifying its attached package rather than rebuilding it. + ## Package Metadata Verify @@ -200,9 +239,10 @@ The 2026-07-09 review completed the following release evidence: - Public API names, status values, runtime dependencies, and the shared-memory layout remain compatible with `1.0.0`. -External publication gate: the GitHub repository reported private -vulnerability reporting as disabled during this review. Enable it or publish an -owner-approved private reporting channel before publishing `1.0.1`. +External publication gates completed on 2026-07-10: GitHub private vulnerability +reporting is enabled for the public repository, the `release` GitHub environment +is restricted to `main`, and the NuGet.org trusted-publishing policy targets +`rantri/SharedMemoryStore`, `release.yml`, and the `release` environment. ## 1.0.0 Documentation and Samples Excellence Notes diff --git a/scripts/get-release-notes.ps1 b/scripts/get-release-notes.ps1 new file mode 100644 index 0000000..7f69302 --- /dev/null +++ b/scripts/get-release-notes.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$')] + [string]$Version, + + [Parameter(Mandatory)] + [string]$OutputPath +) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$changelogPath = Join-Path $root "CHANGELOG.md" +$lines = Get-Content -LiteralPath $changelogPath +$headingPattern = "^##\s+$([regex]::Escape($Version))(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" +$start = -1 + +for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match $headingPattern) { + $start = $index + break + } +} + +if ($start -lt 0) { + throw "CHANGELOG.md does not contain a level-two heading for version $Version." +} + +$end = $lines.Count +for ($index = $start + 1; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match '^##\s+') { + $end = $index + break + } +} + +$notes = ($lines[($start + 1)..($end - 1)] -join [Environment]::NewLine).Trim() +if ([string]::IsNullOrWhiteSpace($notes)) { + throw "CHANGELOG.md has no release notes for version $Version." +} + +$resolvedOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { + [System.IO.Path]::GetFullPath($OutputPath) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root $OutputPath)) +} + +$outputDirectory = Split-Path -Parent $resolvedOutput +New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +Set-Content -LiteralPath $resolvedOutput -Value $notes -Encoding utf8 +Write-Host "Wrote release notes for $Version to $resolvedOutput" diff --git a/scripts/validate-docs.ps1 b/scripts/validate-docs.ps1 index 3da9490..6f01090 100644 --- a/scripts/validate-docs.ps1 +++ b/scripts/validate-docs.ps1 @@ -268,6 +268,7 @@ function Assert-PackageMetadata { Description = "A bounded named shared-memory key-value store for opaque binary values." PackageLicenseExpression = "MIT" PackageReadmeFile = "README.md" + PackageProjectUrl = "https://github.com/rantri/SharedMemoryStore" RepositoryType = "git" RepositoryUrl = "https://github.com/rantri/SharedMemoryStore" } @@ -291,6 +292,10 @@ function Assert-PackageMetadata { Add-Failure "PackageReleaseNotes must mention Linux, Windows, and same-host Docker support." } + if ($propertyGroup.IncludeSymbols -ne "true" -or $propertyGroup.SymbolPackageFormat -ne "snupkg") { + Add-Failure "Package project must produce portable .snupkg symbols." + } + $readmeItem = $project.Project.ItemGroup.None | Where-Object { $_.Include -eq "..\..\README.md" -and $_.Pack -eq "true" -and $_.PackagePath -eq "\" } diff --git a/src/SharedMemoryStore/SharedMemoryStore.csproj b/src/SharedMemoryStore/SharedMemoryStore.csproj index c767a13..19f159b 100644 --- a/src/SharedMemoryStore/SharedMemoryStore.csproj +++ b/src/SharedMemoryStore/SharedMemoryStore.csproj @@ -15,10 +15,13 @@ A bounded named shared-memory key-value store for opaque binary values. shared-memory;memory-mapped-file;zero-copy;linux;windows;docker;library MIT + https://github.com/rantri/SharedMemoryStore git https://github.com/rantri/SharedMemoryStore README.md Linux, Windows, and same-host Docker support hardening: fixes bounded waits, crash-safe ownership and index maintenance, private Linux resource permissions, layout overflow validation, and cleanup reliability while preserving the 1.0.0 public API and layout. + true + snupkg SharedMemoryStore SharedMemoryStore diff --git a/tests/SharedMemoryStore.ContractTests/PackageContractTests.cs b/tests/SharedMemoryStore.ContractTests/PackageContractTests.cs index 6bf5352..68f3752 100644 --- a/tests/SharedMemoryStore.ContractTests/PackageContractTests.cs +++ b/tests/SharedMemoryStore.ContractTests/PackageContractTests.cs @@ -24,7 +24,10 @@ public void PackageProjectCarriesMetadata() Assert.Contains("SharedMemoryStore", project); Assert.Contains("true", project); Assert.Contains("MIT", project); + Assert.Contains("https://github.com/rantri/SharedMemoryStore", project); Assert.Contains("README.md", project); + Assert.Contains("true", project); + Assert.Contains("snupkg", project); Assert.Contains("linux", project, StringComparison.OrdinalIgnoreCase); Assert.Contains("windows", project, StringComparison.OrdinalIgnoreCase); Assert.Contains("docker", project, StringComparison.OrdinalIgnoreCase);