diff --git a/.github/workflows/ci-linux.ym_ b/.github/workflows/ci-linux.ym_ deleted file mode 100644 index eb93780..0000000 --- a/.github/workflows/ci-linux.ym_ +++ /dev/null @@ -1,29 +0,0 @@ -name: ci-linux - -on: - push: - pull_request: - -jobs: - build-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - - name: Install Native AOT prerequisites - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev - - name: Restore - run: dotnet restore Mammoth.LiteMapper.sln - - name: Build - run: dotnet build Mammoth.LiteMapper.sln --no-restore - - name: Test - run: dotnet test Mammoth.LiteMapper.sln --no-build - env: - LITEMAPPER_REQUIRE_NATIVE_AOT: '1' - - name: Validate solution paths - shell: pwsh - run: | - dotnet sln Mammoth.LiteMapper.sln list - dotnet sln Mammoth.LiteMapper.slnx list diff --git a/.github/workflows/ci-windows.ym_ b/.github/workflows/ci-windows.ym_ deleted file mode 100644 index 86e384b..0000000 --- a/.github/workflows/ci-windows.ym_ +++ /dev/null @@ -1,36 +0,0 @@ -name: ci-windows - -on: - push: - pull_request: - -jobs: - build-test: - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - - name: Restore - run: dotnet restore Mammoth.LiteMapper.sln - - name: Build - run: dotnet build Mammoth.LiteMapper.sln --no-restore - - name: Test - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if (-not $installPath) { - throw "Visual Studio C++ toolchain was not found." - } - - $vsDevCmd = Join-Path $installPath "Common7\Tools\VsDevCmd.bat" - cmd /s /c "call `"$vsDevCmd`" -arch=x64 -host_arch=x64 && dotnet test Mammoth.LiteMapper.sln --no-build" - env: - LITEMAPPER_REQUIRE_NATIVE_AOT: '1' - - name: Validate solution paths - shell: pwsh - run: | - dotnet sln Mammoth.LiteMapper.sln list - dotnet sln Mammoth.LiteMapper.slnx list diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 296c47d..b462c3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,17 @@ jobs: env: LITEMAPPER_REQUIRE_NATIVE_AOT: '1' + - name: Run sample smoke validations + if: runner.os == 'Linux' + run: | + dotnet run --project samples/Mammoth.LiteMapper.Samples.Basic/Mammoth.LiteMapper.Samples.Basic.csproj --no-restore + dotnet run --project samples/Mammoth.LiteMapper.Samples.Collections/Mammoth.LiteMapper.Samples.Collections.csproj --no-restore + dotnet run --project samples/Mammoth.LiteMapper.Samples.AspNetCore/Mammoth.LiteMapper.Samples.AspNetCore.csproj --no-restore -- --smoke + + - name: Run benchmark smoke validation + if: runner.os == 'Linux' + run: dotnet run --project benchmarks/Mammoth.LiteMapper.Benchmarks/Mammoth.LiteMapper.Benchmarks.csproj -c Release --no-restore -- --filter '*FlatObjectBenchmarks*' --job Dry --warmupCount 1 --iterationCount 1 + - name: Test (windows) if: runner.os == 'Windows' shell: pwsh @@ -152,29 +163,31 @@ jobs: - name: Test generator with requested Roslyn host run: dotnet test tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj -c Release -p:RoslynTestVersion=${{ matrix.roslyn }} --logger trx - publish: - name: Pack & Publish + pack-and-validate: + name: Pack & Validate needs: [build-and-test, roslyn-hosts] runs-on: windows-latest + outputs: + semver: ${{ steps.gitversion.outputs.semver }} if: > github.event_name != 'pull_request' && ( ( github.event_name != 'workflow_dispatch' && - ( - github.ref == 'refs/heads/main' || - startsWith(github.ref, 'refs/heads/release/') || - startsWith(github.ref, 'refs/heads/hotfix/') - ) + github.ref_type == 'tag' ) || ( github.event_name == 'workflow_dispatch' && - inputs.publish == true && ( inputs.dry_run == true || - github.ref == 'refs/heads/main' || - startsWith(github.ref, 'refs/heads/release/') || - startsWith(github.ref, 'refs/heads/hotfix/') + ( + inputs.publish == true && + ( + github.ref == 'refs/heads/main' || + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/hotfix/') + ) + ) ) ) ) @@ -203,6 +216,19 @@ jobs: throw 'GitVersion did not return a SemVer value.' } + # Only release tags may publish, and their exact valid SemVer must + # agree with GitVersion. Branch dispatches use GitVersion normally. + if ('${{ github.ref_type }}' -eq 'tag') { + $tag = '${{ github.ref_name }}' + $semverPattern = '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' + if ($tag -notmatch $semverPattern) { + throw "Release tag '$tag' is not a valid stable or prerelease SemVer." + } + if ($tag -cne $semver) { + throw "Release tag '$tag' does not match GitVersion SemVer '$semver'." + } + } + "semver=$semver" >> $env:GITHUB_OUTPUT "assembly_semver=$($version.AssemblySemVer)" >> $env:GITHUB_OUTPUT "assembly_file_semver=$($version.AssemblySemFileVer)" >> $env:GITHUB_OUTPUT @@ -233,36 +259,306 @@ jobs: /p:ContinuousIntegrationBuild=true } - $packages = Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -Filter "Mammoth.LiteMapper*.${{ steps.gitversion.outputs.semver }}.nupkg" | - Where-Object { $_.Name -notlike '*.symbols.nupkg' } - if ($packages.Count -ne 3) { - throw "Expected 3 NuGet packages, found $($packages.Count)." + $packageIds = @( + 'Mammoth.LiteMapper.Abstractions', + 'Mammoth.LiteMapper.Generator', + 'Mammoth.LiteMapper' + ) + $packages = @() + $symbolPackages = @() + foreach ($packageId in $packageIds) { + $packagePath = Join-Path '${{ env.PACKAGE_OUTPUT }}' "$packageId.${{ steps.gitversion.outputs.semver }}.nupkg" + $symbolPath = Join-Path '${{ env.PACKAGE_OUTPUT }}' "$packageId.${{ steps.gitversion.outputs.semver }}.snupkg" + if (-not (Test-Path -LiteralPath $packagePath) -or -not (Test-Path -LiteralPath $symbolPath)) { + throw "Missing exact package artifacts for $packageId version ${{ steps.gitversion.outputs.semver }}." + } + $packages += Get-Item -LiteralPath $packagePath + $symbolPackages += Get-Item -LiteralPath $symbolPath + } + $unexpected = Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -File | + Where-Object { $_.Extension -in '.nupkg', '.snupkg' -and $_.FullName -notin ($packages + $symbolPackages).FullName } + if ($unexpected) { + throw "Unexpected package artifacts found: $($unexpected.Name -join ', ')" + } + + $allArtifacts = @($packages + $symbolPackages) + $hashLines = foreach ($artifact in $allArtifacts) { + $hash = (Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256).Hash + "$hash $($artifact.Name)" + } + $hashLines | Set-Content -LiteralPath (Join-Path '${{ env.PACKAGE_OUTPUT }}' 'SHA256SUMS.txt') + [ordered]@{ + commit = '${{ github.sha }}' + ref = '${{ github.ref }}' + version = '${{ steps.gitversion.outputs.semver }}' + artifacts = @($allArtifacts | ForEach-Object { $_.Name }) + } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path '${{ env.PACKAGE_OUTPUT }}' 'provenance.json') + + - name: Validate package contents + shell: pwsh + run: | + Add-Type -AssemblyName System.IO.Compression.FileSystem + $version = '${{ steps.gitversion.outputs.semver }}' + $feed = (Resolve-Path '${{ env.PACKAGE_OUTPUT }}').Path + function Get-PackageEntries([string] $path) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($path) + try { return @($archive.Entries | ForEach-Object { $_.FullName }) } + finally { $archive.Dispose() } + } + $generatorPath = Join-Path $feed "Mammoth.LiteMapper.Generator.$version.nupkg" + $primaryPath = Join-Path $feed "Mammoth.LiteMapper.$version.nupkg" + $generatorEntries = Get-PackageEntries $generatorPath + $primaryEntries = Get-PackageEntries $primaryPath + if (-not ($generatorEntries -contains 'analyzers/dotnet/cs/Mammoth.LiteMapper.Generator.dll')) { + throw 'Generator package is missing its analyzer assembly.' } - $symbolPackages = Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -Filter "Mammoth.LiteMapper*.${{ steps.gitversion.outputs.semver }}.snupkg" - if ($symbolPackages.Count -ne 3) { - throw "Expected 3 symbol packages, found $($symbolPackages.Count)." + if (@($generatorEntries | Where-Object { $_ -match '^(lib|ref)/' })) { + throw 'Generator package contains runtime lib/ref assets.' + } + if (@($generatorEntries + $primaryEntries | Where-Object { $_ -match '(?i)(Microsoft\.CodeAnalysis|Roslyn)' })) { + throw 'Packages contain Roslyn runtime assets.' } - - name: Publish - if: github.event_name != 'workflow_dispatch' || inputs.dry_run == false + - name: Validate final package consumers shell: pwsh - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { - throw 'NUGET_API_KEY secret is required to publish.' + $version = '${{ steps.gitversion.outputs.semver }}' + $feed = (Resolve-Path '${{ env.PACKAGE_OUTPUT }}').Path + foreach ($packageId in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper')) { + dotnet apicompat package (Join-Path $feed "$packageId.$version.nupkg") --run-api-compat } - Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -File -Recurse | - Where-Object { $_.Extension -in '.nupkg', '.snupkg' } | - ForEach-Object { - dotnet nuget push $_.FullName --source https://api.nuget.org/v3/index.json --api-key $env:NUGET_API_KEY --skip-duplicate - } + $consumer = Join-Path $env:RUNNER_TEMP "litemapper-package-consumer-$version" + New-Item -ItemType Directory -Force -Path $consumer | Out-Null + @" + + + Exe + net10.0 + enable + enable + true + + + + + + "@ | Set-Content -LiteralPath (Join-Path $consumer 'Consumer.csproj') + @" + using System.Collections.Generic; + using Mammoth.LiteMapper; + + var source = new Source + { + Value = 7, + Child = new ChildSource { Value = 11 }, + Children = new[] { new ChildSource { Value = 13 } } + }; + var result = StaticMapper.Map(source); + if (result.Value != 7 || result.Child.Value != 11 || result.Children.Count != 1 || result.Children[0].Value != 13) + throw new InvalidOperationException("Static or nested package mapping failed."); + + var instance = new InstanceMapper().Map(new ChildSource { Value = 17 }); + if (instance.Value != 17) throw new InvalidOperationException("Instance package mapping failed."); + + var existing = new ChildTarget { Value = 19 }; + if (!ReferenceEquals(existing, UpdateMapper.Apply(new ChildSource { Value = 23 }, existing)) || existing.Value != 23) + throw new InvalidOperationException("Existing-target package mapping failed."); + + var node = new NodeSource(); + node.Next = node; + try + { + CycleMapper.Map(node); + throw new InvalidOperationException("Cycle detection failed."); + } + catch (LiteMapperCycleException) { } + + public sealed class Source + { + public int Value { get; set; } + public ChildSource Child { get; set; } = new ChildSource(); + public ChildSource[] Children { get; set; } = new ChildSource[0]; + } + public sealed class Target + { + public int Value { get; set; } + public ChildTarget Child { get; set; } = new ChildTarget(); + public List Children { get; set; } = new List(); + } + public sealed class ChildSource { public int Value { get; set; } } + public sealed class ChildTarget { public int Value { get; set; } } + public sealed class NodeSource { public NodeSource? Next { get; set; } } + public sealed class NodeTarget { public NodeTarget? Next { get; set; } } + + [LiteMapper] + public static partial class StaticMapper { public static partial Target Map(Source source); } + [LiteMapper] + public sealed partial class InstanceMapper { public partial ChildTarget Map(ChildSource source); } + [LiteMapper] + public static partial class UpdateMapper { public static partial ChildTarget Apply(ChildSource source, ChildTarget destination); } + [LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] + public static partial class CycleMapper { public static partial NodeTarget Map(NodeSource source); } + "@ | Set-Content -LiteralPath (Join-Path $consumer 'Program.cs') + @" + + + + + + + + + + + + + + + + "@ | Set-Content -LiteralPath (Join-Path $consumer 'NuGet.Config') + dotnet restore (Join-Path $consumer 'Consumer.csproj') --configfile (Join-Path $consumer 'NuGet.Config') --no-cache -r win-x64 + dotnet run --project (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore + $trimmed = Join-Path $consumer 'trimmed' + dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -r win-x64 -p:PublishTrimmed=true -p:PublishDir="$trimmed/" + if (Test-Path (Join-Path $trimmed 'Consumer.exe')) { + & (Join-Path $trimmed 'Consumer.exe') + } else { + dotnet (Join-Path $trimmed 'Consumer.dll') + } + $aot = Join-Path $consumer 'aot' + dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -r win-x64 -p:PublishAot=true -p:TreatWarningsAsErrors=true -p:PublishDir="$aot/" + $aotExecutables = @(Get-ChildItem -LiteralPath $aot -File -Recurse -Filter '*.exe') + if ($aotExecutables.Count -ne 1) { + throw "Expected exactly one Native AOT executable under $aot; found $($aotExecutables.Count)." + } + & $aotExecutables[0].FullName + if ($LASTEXITCODE -ne 0) { + throw "Native AOT consumer exited with code $LASTEXITCODE." + } + + - name: Upload validated packages + uses: actions/upload-artifact@v6 + with: + name: litemapper-packages-${{ steps.gitversion.outputs.semver }} + path: ${{ env.PACKAGE_OUTPUT }} + if-no-files-found: error - name: Dry run summary if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true shell: pwsh run: | - Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -File -Recurse | + "Dry run complete: validated and uploaded packages for ${{ steps.gitversion.outputs.semver }}; no NuGet push was invoked." + + verify-release-artifacts: + name: Verify Release Artifacts + needs: pack-and-validate + runs-on: windows-latest + if: > + github.ref_type == 'tag' || + (github.event_name == 'workflow_dispatch' && + ( + inputs.dry_run == true || + ( + inputs.dry_run == false && + inputs.publish == true && + ( + github.ref == 'refs/heads/main' || + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/hotfix/') + ) + ) + )) + + steps: + - name: Download validated packages + uses: actions/download-artifact@v7 + with: + name: litemapper-packages-${{ needs.pack-and-validate.outputs.semver }} + path: ${{ env.PACKAGE_OUTPUT }} + + - name: Verify validated package set + shell: pwsh + run: | + $version = '${{ needs.pack-and-validate.outputs.semver }}' + $provenancePath = Join-Path '${{ env.PACKAGE_OUTPUT }}' 'provenance.json' + $checksumsPath = Join-Path '${{ env.PACKAGE_OUTPUT }}' 'SHA256SUMS.txt' + if (-not (Test-Path -LiteralPath $provenancePath) -or -not (Test-Path -LiteralPath $checksumsPath)) { + throw 'Validated package provenance or checksum manifest is missing.' + } + $provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json + if ($provenance.commit -cne '${{ github.sha }}' -or $provenance.version -cne $version) { + throw 'Package provenance does not match this commit and version.' + } + if ($provenance.ref -cne '${{ github.ref }}') { + throw 'Package provenance does not match this release ref.' + } + $packageIds = @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper') + $expected = foreach ($packageId in $packageIds) { foreach ($extension in @('nupkg', 'snupkg')) { "$packageId.$version.$extension" } } + if (@($provenance.artifacts | Sort-Object) -join '|' -cne (@($expected | Sort-Object) -join '|')) { + throw 'Package provenance artifact list is not exact.' + } + $actual = @(Get-ChildItem -LiteralPath '${{ env.PACKAGE_OUTPUT }}' -File | Where-Object { $_.Extension -in '.nupkg', '.snupkg' } | - ForEach-Object { "Dry run: would publish $($_.FullName)" } + ForEach-Object { $_.Name }) + if (@($actual | Sort-Object) -join '|' -cne (@($expected | Sort-Object) -join '|')) { + throw 'Downloaded package artifact set is not exact.' + } + $hashes = @{} + foreach ($line in Get-Content -LiteralPath $checksumsPath) { + $parts = $line -split '\s+', 2 + if ($parts.Count -ne 2) { throw "Malformed checksum entry: $line" } + $hashes[$parts[1]] = $parts[0] + } + if ($hashes.Count -ne $expected.Count) { + throw 'Checksum manifest does not contain exactly one entry per package artifact.' + } + foreach ($name in $expected) { + $path = Join-Path '${{ env.PACKAGE_OUTPUT }}' $name + if (-not (Test-Path -LiteralPath $path) -or $hashes[$name] -cne (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash) { + throw "Checksum verification failed for $name." + } + } + + - name: Dry run artifact handoff + if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true + shell: pwsh + run: | + "Dry run artifact handoff verified for ${{ needs.pack-and-validate.outputs.semver }}; no NuGet push was invoked." + + publish: + name: Publish NuGet Packages + needs: [pack-and-validate, verify-release-artifacts] + runs-on: windows-latest + environment: production + if: > + github.ref_type == 'tag' || + (github.event_name == 'workflow_dispatch' && + inputs.dry_run == false && + inputs.publish == true && + ( + github.ref == 'refs/heads/main' || + startsWith(github.ref, 'refs/heads/release/') || + startsWith(github.ref, 'refs/heads/hotfix/') + )) + + steps: + - name: Download verified packages + uses: actions/download-artifact@v7 + with: + name: litemapper-packages-${{ needs.pack-and-validate.outputs.semver }} + path: ${{ env.PACKAGE_OUTPUT }} + + - name: Publish exact packages + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { throw 'NUGET_API_KEY secret is required to publish.' } + $version = '${{ needs.pack-and-validate.outputs.semver }}' + foreach ($packageId in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) { + foreach ($extension in @('nupkg', 'snupkg')) { + $path = Join-Path '${{ env.PACKAGE_OUTPUT }}' "$packageId.$version.$extension" + dotnet nuget push $path --source https://api.nuget.org/v3/index.json --api-key $env:NUGET_API_KEY + } + } diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1d76335..976072a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #17: release quality and test discovery + +- Scope: harden the canonical release workflow and helper for bare stable/prerelease SemVer tags, exact six-artifact handoff, checksums/provenance, package/API/consumer/AOT validation, explicit test discovery, analyzer-only generator packaging, and synchronized release documentation. +- Workflow handoff: `.github/workflows/ci.yml` performs one pack/validate/upload phase, a checksum/provenance verification phase, and an approval-gated publish phase. Dry runs execute the validation and artifact handoff but never invoke NuGet push. Artifact transfer uses the Node 24-compatible `actions/upload-artifact@v6` and `actions/download-artifact@v7`. `publish-nuget.ps1` enforces the same exact six-artifact and no-duplicate-suppression contract for local releases. +- Validation target: run `dotnet test tests\Mammoth.LiteMapper.Packaging.Tests\Mammoth.LiteMapper.Packaging.Tests.csproj --no-restore --filter ReleaseQualityContractTests`, the Milestone 14 packaging filter, and the complete solution test command. +- Current evidence (2026-09-17): ReleaseQualityContractTests 17/17; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; the previous full solution run passed 649 with the same one expected skip; all six local packages packed successfully and passed package-content checks; Basic, Collections, and ASP.NET Core smoke samples passed sequentially. The Windows package consumer restore and no-restore trimmed/AOT publishes use an explicit `win-x64` runtime, and the Native AOT executable is discovered recursively instead of assuming a fixed output path. Artifact transfer now uses Node 24-compatible action releases. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); the first remote consumer validation exposed NETSDK1047, and the follow-up exposed a hard-coded AOT executable path; both are fixed in the pending PR rerun. + Current execution state (2026-09-16): W01 through W10 and the approved Milestone 13 flat-mapping optimization remain complete. GitHub issues #1 through #7 are implemented, documented, merged, and closed. Issue #7 was merged into `develop` via PR [#10](https://github.com/PrimordialCode/Mammoth.LiteMapper/pull/10) at `a8ac8bd`; AgentStack is `done` and its claim is released. Existing source-policy behavior already satisfies the requested mapper, method, assembly, severity, and editorconfig boundaries; this scope added adoption evidence and synchronized guidance without a public API or semantic change. Remote CI remains unexecuted. PENDING-0006 and PENDING-0007 Option A are approved, incorporated, implemented, and regression-tested. All milestone and dated checkpoint sections below are historical planning and revision-specific evidence; their pending-work statements are superseded by the current execution state above. diff --git a/STATUS.md b/STATUS.md index f421d86..82ba3b2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Status +## GitHub issue #17 release quality checkpoint (2026-09-17) + +- Scope: canonical CI/release hardening for bare stable/prerelease SemVer tags, exact six-artifact handoff, package/API/consumer/AOT validation, explicit MSTest discovery, analyzer-only generator packaging, and release documentation. +- Workflow seam: `.github/workflows/ci.yml` validates the exact tag/GitVersion version before one pack, verifies checksums/provenance and package contents, uploads the validated packages with `actions/upload-artifact@v6`, and publishes only from a protected tag or approved production dispatch after `actions/download-artifact@v7`. Dry-run follows the same validation and handoff path and never calls `dotnet nuget push`. +- Local helper seam: `publish-nuget.ps1` accepts the same stable/prerelease versioned artifacts, requires exactly three .nupkg plus three .snupkg, and does not suppress duplicate push failures. +- Current evidence (2026-09-17): ReleaseQualityContractTests 17/17; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; the previous full solution run passed 649 with the same one expected skip; six local packages packed and content-checked; Basic, Collections, and ASP.NET Core smoke samples passed sequentially. The Windows package consumer restore and no-restore trimmed/AOT publishes use an explicit `win-x64` runtime after the first remote run exposed NETSDK1047; Native AOT validation now discovers exactly one executable recursively after the follow-up exposed a hard-coded output path. Artifact transfer now uses Node 24-compatible action releases. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); the corrected remote rerun and NuGet publication are not yet claimed. + Latest completed work: GitHub issue #7 strict source-member completeness was merged into `develop` via PR [#10](https://github.com/PrimordialCode/Mammoth.LiteMapper/pull/10) at `a8ac8bd`, and closed. AgentStack issue #7 is `done` and its claim is released. Issue #6 existing-target update atomicity was previously merged into `develop` via PR [#9](https://github.com/PrimordialCode/Mammoth.LiteMapper/pull/9) and closed. Issue #5 declaration, registration, constructor, and accessibility coverage was also previously merged into `develop` and closed. - Current scope: issue #7 strict source-member completeness adoption evidence and synchronized documentation is complete; remote CI remains unexecuted. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..b05d24f --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,26 @@ +# Release checklist + +The canonical release workflow is .github/workflows/ci.yml. Releases use bare SemVer tags, for example 2.0.0, 2.0.0-alpha.1, or 2.0.1-beta.1. + +## Before release + +- Confirm the tag exactly matches the GitVersion SemVer value. +- Confirm the tag is protected and the production environment requires the configured human approval. +- Run the workflow manually with dry_run=true to rehearse the complete validation path. + +## Validation and artifacts + +- Build and test the solution on Linux and Windows. +- Run the pinned Roslyn host matrix. +- Pack the three shipping projects once at the exact tag version. +- Verify exactly one .nupkg and one .snupkg for each package. +- Verify package API compatibility, package contents, clean-consumer mappings, trimming, Native AOT, checksums, and provenance. +- Ensure the Windows consumer restore includes the `win-x64` runtime before no-restore trimmed/AOT publishes. +- Use Node 24-compatible artifact actions: `actions/upload-artifact@v6` and `actions/download-artifact@v7`. +- Retain the uploaded package, checksum, and provenance artifacts. + +## Publish + +- Publish only from a valid SemVer tag or an explicitly approved production workflow dispatch on main, release/**, or hotfix/**. +- The publish job downloads and verifies the uploaded artifacts, then pushes those exact six files without duplicate suppression. +- A dry run must report the artifacts that would be published and must never call dotnet nuget push or mutate NuGet. diff --git a/publish-nuget.ps1 b/publish-nuget.ps1 index 547f1e5..fef2b97 100644 --- a/publish-nuget.ps1 +++ b/publish-nuget.ps1 @@ -156,20 +156,32 @@ foreach ($project in $packProjects) { Assert-NativeSuccess "dotnet pack $project -c Release --no-restore -o $resolvedOutput with GitVersion properties" } -$packages = Get-ChildItem -Path $resolvedOutput -Filter "Mammoth.LiteMapper*.$version.nupkg" | - Where-Object { $_.Name -notlike '*.symbols.nupkg' } | - Sort-Object Name - -if ($packages.Count -ne 3) { - throw "Expected 3 packages for version $version, found $($packages.Count)." -} - -foreach ($package in $packages) { - if ($DryRun) { - Write-Host "Dry run: would publish $($package.FullName) to $Source" - continue - } - - & dotnet nuget push $package.FullName --source $Source --api-key $ApiKey --skip-duplicate - Assert-NativeSuccess "dotnet nuget push $($package.FullName) --source $Source --api-key *** --skip-duplicate" -} +$packageIds = @( + 'Mammoth.LiteMapper.Abstractions', + 'Mammoth.LiteMapper.Generator', + 'Mammoth.LiteMapper' +) +$expectedArtifacts = foreach ($packageId in $packageIds) { + foreach ($extension in @('nupkg', 'snupkg')) { + "$packageId.$version.$extension" + } +} +$artifacts = @(Get-ChildItem -Path $resolvedOutput -File | + Where-Object { $_.Extension -in '.nupkg', '.snupkg' } | + Sort-Object Name) +$unexpected = @($artifacts | Where-Object { $_.Name -notin $expectedArtifacts }) +$missing = @($expectedArtifacts | Where-Object { -not (Test-Path -LiteralPath (Join-Path $resolvedOutput $_)) }) +if ($unexpected.Count -ne 0 -or $missing.Count -ne 0 -or $artifacts.Count -ne $expectedArtifacts.Count) { + throw "Expected exactly $($expectedArtifacts.Count) versioned package artifacts for $version; missing: $($missing -join ', '); unexpected: $($unexpected.Name -join ', ')." +} + +foreach ($artifactName in $expectedArtifacts) { + $artifactPath = Join-Path $resolvedOutput $artifactName + if ($DryRun) { + Write-Host "Dry run: would publish $artifactPath to $Source" + continue + } + + & dotnet nuget push $artifactPath --source $Source --api-key $ApiKey + Assert-NativeSuccess "dotnet nuget push $artifactPath --source $Source --api-key ***" +} diff --git a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj index feb699d..afdfa03 100644 --- a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj +++ b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj @@ -2,6 +2,7 @@ netstandard2.0 true + true Source generator package for Mammoth.LiteMapper. true $(NoWarn);RS2008 @@ -14,4 +15,10 @@ PackagePath="analyzers/dotnet/cs" Visible="false" /> + + + + + + diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj b/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj index 2eb97f7..0ac6047 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj +++ b/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + true false 4.8.0 diff --git a/tests/Mammoth.LiteMapper.IntegrationTests/Mammoth.LiteMapper.IntegrationTests.csproj b/tests/Mammoth.LiteMapper.IntegrationTests/Mammoth.LiteMapper.IntegrationTests.csproj index 0b94ad3..973eca4 100644 --- a/tests/Mammoth.LiteMapper.IntegrationTests/Mammoth.LiteMapper.IntegrationTests.csproj +++ b/tests/Mammoth.LiteMapper.IntegrationTests/Mammoth.LiteMapper.IntegrationTests.csproj @@ -1,6 +1,7 @@ net10.0 + true false diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj index 9da73dc..8766b16 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj @@ -1,6 +1,7 @@ net10.0 + true false diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index aba83f4..9cf49f0 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -60,6 +60,10 @@ public void GeneratorPackageContainsAnalyzerAsset() var entries = archive.Entries.Select(static e => e.FullName).ToArray(); CollectionAssert.Contains(entries, "analyzers/dotnet/cs/Mammoth.LiteMapper.Generator.dll"); + Assert.IsFalse(entries.Any(static e => e.StartsWith("lib/", StringComparison.Ordinal) || e.StartsWith("ref/", StringComparison.Ordinal)), + string.Join(Environment.NewLine, entries)); + Assert.IsFalse(entries.Any(static e => e.Contains("Microsoft.CodeAnalysis", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, entries)); } [TestMethod] diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs new file mode 100644 index 0000000..f64316e --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Packaging.Tests +{ + [TestClass] + public sealed class ReleaseQualityContractTests + { + private static readonly Regex SemVer = new Regex( + "^(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)(?
-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
+            RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+        [TestMethod]
+        [DataRow("1.0.0", "stable")]
+        [DataRow("1.2.3-alpha.1", "alpha")]
+        [DataRow("2.0.0-beta.4", "beta")]
+        public void BareSemVerTagsResolveToTheirReleaseChannel(string tag, string channel)
+        {
+            Assert.AreEqual(channel, ResolveChannel(tag));
+        }
+
+        [TestMethod]
+        [DataRow("1.2.3", "1.2.3", true)]
+        [DataRow("1.2.3-alpha.1", "1.2.3-alpha.1", true)]
+        [DataRow("1.2.3-beta.1", "1.2.3-alpha.1", false)]
+        [DataRow("1.2.4", "1.2.3", false)]
+        public void TagMustMatchTheVersionBeingPublished(string tag, string version, bool expected)
+        {
+            Assert.AreEqual(expected, string.Equals(tag, version, StringComparison.Ordinal));
+        }
+
+        [TestMethod]
+        [DataRow("v1.0.0")]
+        [DataRow("1.0")]
+        [DataRow("1.0.0+©")]
+        [DataRow("refs/tags/1.0.0")]
+        public void InvalidOrNonBareTagsAreRejected(string tag)
+        {
+            Assert.IsFalse(TryResolveChannel(tag, out _), tag);
+        }
+
+        [TestMethod]
+        public void DryRunExecutesWithoutNuGetPushAndLeavesExistingPackageUntouched()
+        {
+            var relativeOutput = Path.Combine("artifacts", "release-quality-" + Guid.NewGuid().ToString("N"));
+            var output = Path.Combine(Repository.Root, relativeOutput);
+            Directory.CreateDirectory(output);
+            var sentinel = Path.Combine(output, "sentinel.txt");
+            File.WriteAllText(sentinel, "must remain");
+            try
+            {
+                var command = @"
+function dotnet {
+    $CommandArgs = $args
+    $global:LASTEXITCODE = 0
+    $joined = $CommandArgs -join ' '
+    if ($joined -like '*gitversion*') {
+        '{""SemVer"":""1.2.3-beta.1"",""AssemblySemVer"":""1.2.3.0"",""AssemblySemFileVer"":""1.2.3.0"",""InformationalVersion"":""1.2.3-beta.1""}'
+        return
+    }
+    if ($joined -like 'pack *') {
+        $index = [Array]::IndexOf($CommandArgs, '-o')
+        $destination = $CommandArgs[$index + 1]
+        New-Item -ItemType Directory -Force -Path $destination | Out-Null
+        foreach ($id in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) {
+            Set-Content -Path (Join-Path $destination ($id + '.1.2.3-beta.1.nupkg')) -Value $id
+            Set-Content -Path (Join-Path $destination ($id + '.1.2.3-beta.1.snupkg')) -Value $id
+        }
+        return
+    }
+    if ($joined -like '*nuget push*') { Set-Content -Path (Join-Path (Get-Location) 'push.marker') -Value pushed }
+}
+& '" + Path.Combine(Repository.Root, "publish-nuget.ps1") + @"' -Source local -OutputDirectory '" + relativeOutput.Replace("'", "''") + @"' -SkipValidation -DryRun
+";
+                var encoded = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(command));
+                var result = TestProcess.Run("pwsh", "-NoProfile -EncodedCommand " + encoded, Repository.Root, TimeSpan.FromMinutes(1));
+                Assert.AreEqual(0, result.ExitCode, result.Output + Environment.NewLine + result.Error);
+                Assert.IsFalse(File.Exists(Path.Combine(Repository.Root, "push.marker")), "Dry-run invoked NuGet push.");
+                Assert.AreEqual("must remain", File.ReadAllText(sentinel));
+            }
+            finally
+            {
+                if (Directory.Exists(output)) Directory.Delete(output, recursive: true);
+                var marker = Path.Combine(Repository.Root, "push.marker");
+                if (File.Exists(marker)) File.Delete(marker);
+            }
+        }
+
+        [TestMethod]
+        public void ReleaseScriptPublishesExactSymbolsAndDoesNotSkipDuplicates()
+        {
+            var script = File.ReadAllText(Path.Combine(Repository.Root, "publish-nuget.ps1"));
+            StringAssert.Contains(script, "'.snupkg'");
+            StringAssert.Contains(script, "Expected exactly");
+            Assert.IsFalse(script.Contains("--skip-duplicate", StringComparison.OrdinalIgnoreCase));
+        }
+
+        [TestMethod]
+        public void ReleaseArtifactsMustContainAllThreeVersionedPackages()
+        {
+            var feed = Milestone14PackagingAndAotTests.CreateFeed();
+            foreach (var id in new[] { "Mammoth.LiteMapper.Abstractions", "Mammoth.LiteMapper.Generator", "Mammoth.LiteMapper" })
+            {
+                File.WriteAllText(Path.Combine(feed, id + ".1.0.0.nupkg"), id);
+            }
+
+            var packages = Directory.GetFiles(feed, "Mammoth.LiteMapper*.1.0.0.nupkg")
+                .Where(path => !path.EndsWith(".symbols.nupkg", StringComparison.Ordinal))
+                .ToArray();
+            Assert.AreEqual(3, packages.Length);
+            CollectionAssert.AreEquivalent(
+                new[] { "Mammoth.LiteMapper.Abstractions.1.0.0.nupkg", "Mammoth.LiteMapper.Generator.1.0.0.nupkg", "Mammoth.LiteMapper.1.0.0.nupkg" },
+                packages.Select(Path.GetFileName).ToArray());
+        }
+
+        [TestMethod]
+        public void FinalPackageConsumerRestoresWindowsRuntimeBeforeNoRestorePublish()
+        {
+            var workflow = File.ReadAllText(Path.Combine(
+                Repository.Root, ".github", "workflows", "ci.yml"));
+
+            StringAssert.Contains(
+                workflow,
+                "dotnet restore (Join-Path $consumer 'Consumer.csproj') --configfile (Join-Path $consumer 'NuGet.Config') --no-cache -r win-x64");
+            StringAssert.Contains(
+                workflow,
+                "dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -r win-x64 -p:PublishTrimmed=true");
+            StringAssert.Contains(
+                workflow,
+                "dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -r win-x64 -p:PublishAot=true");
+        }
+
+        [TestMethod]
+        public void FinalPackageConsumerDiscoversTheNativeAotExecutable()
+        {
+            var workflow = File.ReadAllText(Path.Combine(
+                Repository.Root, ".github", "workflows", "ci.yml"));
+
+            StringAssert.Contains(
+                workflow,
+                "$aotExecutables = @(Get-ChildItem -LiteralPath $aot -File -Recurse -Filter '*.exe')");
+            StringAssert.Contains(workflow, "if ($aotExecutables.Count -ne 1)");
+            StringAssert.Contains(workflow, "& $aotExecutables[0].FullName");
+            Assert.IsFalse(workflow.Contains(
+                "& (Join-Path $aot 'Consumer.exe')",
+                StringComparison.Ordinal));
+        }
+
+        [TestMethod]
+        public void ArtifactActionsUseNode24CompatibleReleases()
+        {
+            var workflow = File.ReadAllText(Path.Combine(
+                Repository.Root, ".github", "workflows", "ci.yml"));
+
+            StringAssert.Contains(workflow, "actions/upload-artifact@v6");
+            StringAssert.Contains(workflow, "actions/download-artifact@v7");
+            Assert.IsFalse(workflow.Contains("actions/upload-artifact@v4", StringComparison.Ordinal));
+            Assert.IsFalse(workflow.Contains("actions/download-artifact@v4", StringComparison.Ordinal));
+        }
+
+        private static string ResolveChannel(string tag)
+        {
+            Assert.IsTrue(TryResolveChannel(tag, out var channel), tag);
+            return channel!;
+        }
+
+        private static bool TryResolveChannel(string tag, out string? channel)
+        {
+            var match = SemVer.Match(tag);
+            if (!match.Success)
+            {
+                channel = null;
+                return false;
+            }
+
+            channel = match.Groups["pre"].Success
+                ? match.Groups["pre"].Value.StartsWith("-alpha", StringComparison.Ordinal) ? "alpha" : "beta"
+                : "stable";
+            return true;
+        }
+    }
+}
diff --git a/tests/Mammoth.LiteMapper.Runtime.Tests/Mammoth.LiteMapper.Runtime.Tests.csproj b/tests/Mammoth.LiteMapper.Runtime.Tests/Mammoth.LiteMapper.Runtime.Tests.csproj
index e139456..c3bc5e2 100644
--- a/tests/Mammoth.LiteMapper.Runtime.Tests/Mammoth.LiteMapper.Runtime.Tests.csproj
+++ b/tests/Mammoth.LiteMapper.Runtime.Tests/Mammoth.LiteMapper.Runtime.Tests.csproj
@@ -1,6 +1,7 @@
 
   
     net10.0
+    true
     false