From 2a0b8a41dffe6baeb31fda8e0de666488da9fd35 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 17 Sep 2026 10:50:00 +0200 Subject: [PATCH 1/8] ci: harden NuGet release workflow Co-authored-by: Codex --- .github/workflows/ci.yml | 155 ++++++++++++++++++++++++++++++--------- 1 file changed, 122 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 296c47d..759b714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,29 +152,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 +205,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 +248,110 @@ 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 } - $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)." + $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: Upload validated packages + uses: actions/upload-artifact@v4 + with: + name: litemapper-packages-${{ steps.gitversion.outputs.semver }} + path: ${{ env.PACKAGE_OUTPUT }} + if-no-files-found: error + - name: Publish - if: github.event_name != 'workflow_dispatch' || inputs.dry_run == false + if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true 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.' - } + "Dry run complete: validated and uploaded packages for ${{ steps.gitversion.outputs.semver }}; no NuGet push was invoked." + + publish: + name: Publish Validated Packages + needs: pack-and-validate + runs-on: windows-latest + environment: production + if: > + github.ref_type == 'tag' || + (github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.publish == true) + + steps: + - name: Download validated packages + uses: actions/download-artifact@v4 + with: + name: litemapper-packages-${{ needs.pack-and-validate.outputs.semver }} + path: ${{ env.PACKAGE_OUTPUT }} - 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 + - 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.' + } + $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.' + } + $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] + } + 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 summary - if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true + - name: Publish shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - Get-ChildItem -Path '${{ env.PACKAGE_OUTPUT }}' -File -Recurse | - Where-Object { $_.Extension -in '.nupkg', '.snupkg' } | - ForEach-Object { "Dry run: would publish $($_.FullName)" } + 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')) { + dotnet nuget push (Join-Path '${{ env.PACKAGE_OUTPUT }}' "$packageId.$version.$extension") --source https://api.nuget.org/v3/index.json --api-key $env:NUGET_API_KEY + } + } From 97bf04c02a8a34ee6244c9525964ce0c504276e8 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 17 Sep 2026 10:53:37 +0200 Subject: [PATCH 2/8] test: harden release quality contracts Co-authored-by: Codex --- IMPLEMENTATION_PLAN.md | 6 + STATUS.md | 7 + .../Mammoth.LiteMapper.Generator.Tests.csproj | 1 + ...Mammoth.LiteMapper.IntegrationTests.csproj | 1 + .../Mammoth.LiteMapper.Packaging.Tests.csproj | 1 + .../ReleaseQualityContractTests.cs | 130 ++++++++++++++++++ .../Mammoth.LiteMapper.Runtime.Tests.csproj | 1 + 7 files changed, 147 insertions(+) create mode 100644 tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1d76335..3a0797b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,11 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #17: release quality and test discovery + +- Scope: add explicit `true` to the four test projects and add deterministic packaging-test contracts for release tag/channel validation, rejection of invalid or non-bare tags, dry-run non-publication ordering, and the complete three-package artifact set. +- Workflow handoff: the release workflow/script owns tag extraction, exact version propagation, and the actual no-mutation dry-run. It must call the same acceptance rules represented by `ReleaseQualityContractTests` before any push. No source projects, workflow YAML, or public APIs are changed in this worktree. +- Validation target: run `dotnet test tests\Mammoth.LiteMapper.Packaging.Tests\Mammoth.LiteMapper.Packaging.Tests.csproj --no-restore --filter ReleaseQualityContractTests`, then the existing Milestone 14 packaging filter where local prerequisites permit. + 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..fb5326a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Status +## GitHub issue #17 release quality checkpoint (2026-09-17) + +- Scope: explicit MSTest discovery metadata on the four test projects and focused packaging-test contracts for bare stable/alpha/beta SemVer tags, invalid/non-bare tag rejection, dry-run publication ordering, and the three required versioned package artifacts. +- Existing package validation remains authoritative for final artifacts: `Milestone14PackagingAndAotTests` covers package contents, deterministic fingerprints, API compatibility, trimming, and Native AOT; the Native AOT case may be inconclusive on Windows when linker prerequisites are unavailable. +- Workflow seam: the release workflow/script must resolve and validate the Git tag before packing, pass the resolved version to all three packs, and perform no `dotnet nuget push` when dry-run is selected. The tests intentionally do not invoke NuGet or mutate a remote feed. +- Local focused validation is pending this branch's test run; remote CI and publication are not 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/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/ReleaseQualityContractTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs new file mode 100644 index 0000000..6157be5 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs @@ -0,0 +1,130 @@ +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
+        }
+        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 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());
+        }
+
+        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
   
   

From 2251b9d9f0032c9b6305ede904b9b1bd13490dff Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 10:50:00 +0200
Subject: [PATCH 3/8] ci: harden NuGet release workflow

Co-authored-by: Codex 
---
 .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 49 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 759b714..79bc0d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -283,6 +283,55 @@ jobs:
             artifacts = @($allArtifacts | ForEach-Object { $_.Name })
           } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path '${{ env.PACKAGE_OUTPUT }}' 'provenance.json')
 
+      - name: Validate final package consumers
+        shell: pwsh
+        run: |
+          $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
+          }
+
+          $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 Mammoth.LiteMapper;
+          var result = Mapper.Map(new Source { Value = 7 });
+          if (result.Value != 7) throw new InvalidOperationException("Generated package mapping failed.");
+          public sealed class Source { public int Value { get; set; } }
+          public sealed class Target { public int Value { get; set; } }
+          [LiteMapper]
+          public static partial class Mapper { public static partial Target Map(Source 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
+          dotnet run --project (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore
+          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -p:PublishTrimmed=true
+          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -p:PublishAot=true -p:TreatWarningsAsErrors=true
+          & (Join-Path $consumer 'bin\Release\net10.0\win-x64\publish\Consumer.exe')
+
       - name: Upload validated packages
         uses: actions/upload-artifact@v4
         with:

From bc2f25c6b4ca94afc41703ff0739151975617b2e Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 11:57:07 +0200
Subject: [PATCH 4/8] ci: harden release pipeline and package validation

Co-authored-by: Codex 
---
 .github/workflows/ci-linux.ym_                |  29 ---
 .github/workflows/ci-windows.ym_              |  36 ----
 .github/workflows/ci.yml                      | 170 ++++++++++++++++--
 IMPLEMENTATION_PLAN.md                        |   7 +-
 STATUS.md                                     |   8 +-
 docs/RELEASE_CHECKLIST.md                     |  24 +++
 publish-nuget.ps1                             |  46 +++--
 .../Mammoth.LiteMapper.Generator.csproj       |   7 +
 .../Milestone14PackagingAndAotTests.cs        |   4 +
 .../ReleaseQualityContractTests.cs            |  10 ++
 10 files changed, 237 insertions(+), 104 deletions(-)
 delete mode 100644 .github/workflows/ci-linux.ym_
 delete mode 100644 .github/workflows/ci-windows.ym_
 create mode 100644 docs/RELEASE_CHECKLIST.md

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 79bc0d4..2f5ecd5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -283,6 +283,31 @@ jobs:
             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.'
+          }
+          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: Validate final package consumers
         shell: pwsh
         run: |
@@ -309,13 +334,60 @@ jobs:
           
           "@ | Set-Content -LiteralPath (Join-Path $consumer 'Consumer.csproj')
           @"
+          using System.Collections.Generic;
           using Mammoth.LiteMapper;
-          var result = Mapper.Map(new Source { Value = 7 });
-          if (result.Value != 7) throw new InvalidOperationException("Generated package mapping failed.");
-          public sealed class Source { public int Value { get; set; } }
-          public sealed class Target { public int Value { get; set; } }
+
+          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 Mapper { public static partial Target Map(Source source); }
+          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')
           @"
           
@@ -324,13 +396,28 @@ jobs:
               
               
             
+            
+              
+                
+              
+              
+                
+              
+            
           
           "@ | Set-Content -LiteralPath (Join-Path $consumer 'NuGet.Config')
           dotnet restore (Join-Path $consumer 'Consumer.csproj') --configfile (Join-Path $consumer 'NuGet.Config') --no-cache
           dotnet run --project (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore
-          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -p:PublishTrimmed=true
-          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -p:PublishAot=true -p:TreatWarningsAsErrors=true
-          & (Join-Path $consumer 'bin\Release\net10.0\win-x64\publish\Consumer.exe')
+          $trimmed = Join-Path $consumer 'trimmed'
+          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -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 -p:PublishAot=true -p:TreatWarningsAsErrors=true -p:PublishDir="$aot/"
+          & (Join-Path $aot 'Consumer.exe')
 
       - name: Upload validated packages
         uses: actions/upload-artifact@v4
@@ -339,20 +426,31 @@ jobs:
           path: ${{ env.PACKAGE_OUTPUT }}
           if-no-files-found: error
 
-      - name: Publish
+      - name: Dry run summary
         if: github.event_name == 'workflow_dispatch' && inputs.dry_run == true
         shell: pwsh
         run: |
           "Dry run complete: validated and uploaded packages for ${{ steps.gitversion.outputs.semver }}; no NuGet push was invoked."
 
-  publish:
-    name: Publish Validated Packages
+  verify-release-artifacts:
+    name: Verify Release Artifacts
     needs: pack-and-validate
     runs-on: windows-latest
-    environment: production
     if: >
       github.ref_type == 'tag' ||
-      (github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.publish == true)
+      (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
@@ -374,17 +472,29 @@ jobs:
           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 { $_.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) {
@@ -392,7 +502,36 @@ jobs:
             }
           }
 
-      - name: Publish
+      - 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@v4
+        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 }}
@@ -401,6 +540,7 @@ jobs:
           $version = '${{ needs.pack-and-validate.outputs.semver }}'
           foreach ($packageId in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) {
             foreach ($extension in @('nupkg', 'snupkg')) {
-              dotnet nuget push (Join-Path '${{ env.PACKAGE_OUTPUT }}' "$packageId.$version.$extension") --source https://api.nuget.org/v3/index.json --api-key $env:NUGET_API_KEY
+              $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 3a0797b..3da4666 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -2,9 +2,10 @@
 
 ## Issue #17: release quality and test discovery
 
-- Scope: add explicit `true` to the four test projects and add deterministic packaging-test contracts for release tag/channel validation, rejection of invalid or non-bare tags, dry-run non-publication ordering, and the complete three-package artifact set.
-- Workflow handoff: the release workflow/script owns tag extraction, exact version propagation, and the actual no-mutation dry-run. It must call the same acceptance rules represented by `ReleaseQualityContractTests` before any push. No source projects, workflow YAML, or public APIs are changed in this worktree.
-- Validation target: run `dotnet test tests\Mammoth.LiteMapper.Packaging.Tests\Mammoth.LiteMapper.Packaging.Tests.csproj --no-restore --filter ReleaseQualityContractTests`, then the existing Milestone 14 packaging filter where local prerequisites permit.
+- 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. `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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; solution 649 passed with the same one expected skip; all six local packages packed successfully and passed package-content checks. `dotnet tool restore` and package-level `apicompat` were blocked by local NuGet SSL access; remote Actions has not yet run.
 
 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.
 
diff --git a/STATUS.md b/STATUS.md
index fb5326a..1107190 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -2,10 +2,10 @@
 
 ## GitHub issue #17 release quality checkpoint (2026-09-17)
 
-- Scope: explicit MSTest discovery metadata on the four test projects and focused packaging-test contracts for bare stable/alpha/beta SemVer tags, invalid/non-bare tag rejection, dry-run publication ordering, and the three required versioned package artifacts.
-- Existing package validation remains authoritative for final artifacts: `Milestone14PackagingAndAotTests` covers package contents, deterministic fingerprints, API compatibility, trimming, and Native AOT; the Native AOT case may be inconclusive on Windows when linker prerequisites are unavailable.
-- Workflow seam: the release workflow/script must resolve and validate the Git tag before packing, pass the resolved version to all three packs, and perform no `dotnet nuget push` when dry-run is selected. The tests intentionally do not invoke NuGet or mutate a remote feed.
-- Local focused validation is pending this branch's test run; remote CI and publication are not claimed.
+- 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, and publishes only from a protected tag or approved production dispatch. 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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; full solution 649 passed with the same one expected skip; six local packages packed and content-checked. `dotnet tool restore` and package-level `apicompat` were blocked by local NuGet SSL access; remote Actions and NuGet publication are not 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.
 
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..ba412db
--- /dev/null
+++ b/docs/RELEASE_CHECKLIST.md
@@ -0,0 +1,24 @@
+# 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.
+- 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.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
index 6157be5..233f522 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
@@ -67,6 +67,7 @@ function dotnet {
         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
     }
@@ -88,6 +89,15 @@ function dotnet {
             }
         }
 
+        [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()
         {

From 2e4516c23a7300c7e95f7097ac47089b43003f8f Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 12:05:24 +0200
Subject: [PATCH 5/8] ci: run sample and benchmark smoke checks

Co-authored-by: Codex 
---
 .github/workflows/ci.yml | 11 +++++++++++
 IMPLEMENTATION_PLAN.md   |  2 +-
 STATUS.md                |  2 +-
 3 files changed, 13 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f5ecd5..6c34be5 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
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 3da4666..ae22f70 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -5,7 +5,7 @@
 - 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. `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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; solution 649 passed with the same one expected skip; all six local packages packed successfully and passed package-content checks. `dotnet tool restore` and package-level `apicompat` were blocked by local NuGet SSL access; remote Actions has not yet run.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; solution 649 passed 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. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); remote Actions has not yet run.
 
 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.
 
diff --git a/STATUS.md b/STATUS.md
index 1107190..a5d84d7 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -5,7 +5,7 @@
 - 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, and publishes only from a protected tag or approved production dispatch. 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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; full solution 649 passed with the same one expected skip; six local packages packed and content-checked. `dotnet tool restore` and package-level `apicompat` were blocked by local NuGet SSL access; remote Actions and NuGet publication are not claimed.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; full solution 649 passed with the same one expected skip; six local packages packed and content-checked; Basic, Collections, and ASP.NET Core smoke samples passed sequentially. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); remote Actions and NuGet publication are not 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.
 

From 85eff0d46aa7e47235af02fa407136223d76cf9c Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 12:51:08 +0200
Subject: [PATCH 6/8] ci: restore Windows runtime for package consumer publish

Co-authored-by: Codex 
---
 .github/workflows/ci.yml                        |  6 +++---
 IMPLEMENTATION_PLAN.md                          |  2 +-
 STATUS.md                                       |  2 +-
 docs/RELEASE_CHECKLIST.md                       |  1 +
 .../ReleaseQualityContractTests.cs              | 17 +++++++++++++++++
 5 files changed, 23 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6c34be5..a1e9c08 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -417,17 +417,17 @@ jobs:
             
           
           "@ | Set-Content -LiteralPath (Join-Path $consumer 'NuGet.Config')
-          dotnet restore (Join-Path $consumer 'Consumer.csproj') --configfile (Join-Path $consumer 'NuGet.Config') --no-cache
+          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 -p:PublishTrimmed=true -p:PublishDir="$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 -p:PublishAot=true -p:TreatWarningsAsErrors=true -p:PublishDir="$aot/"
+          dotnet publish (Join-Path $consumer 'Consumer.csproj') -c Release --no-restore -r win-x64 -p:PublishAot=true -p:TreatWarningsAsErrors=true -p:PublishDir="$aot/"
           & (Join-Path $aot 'Consumer.exe')
 
       - name: Upload validated packages
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index ae22f70..a01e694 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -5,7 +5,7 @@
 - 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. `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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; solution 649 passed 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. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); remote Actions has not yet run.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 15/15; 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 now use an explicit `win-x64` runtime. `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 is 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.
 
diff --git a/STATUS.md b/STATUS.md
index a5d84d7..819ca64 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -5,7 +5,7 @@
 - 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, and publishes only from a protected tag or approved production dispatch. 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 14/14; solution build 0 warnings/errors; Milestone 14 24 passed with one expected Windows Native AOT prerequisite skip; full solution 649 passed with the same one expected skip; six local packages packed and content-checked; Basic, Collections, and ASP.NET Core smoke samples passed sequentially. `dotnet tool restore`, package-level `apicompat`, and the BenchmarkDotNet generated-project smoke were blocked by local NuGet SSL access (zero benchmark cases executed); remote Actions and NuGet publication are not claimed.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 15/15; 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 now use an explicit `win-x64` runtime after the first remote run exposed NETSDK1047. `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.
 
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index ba412db..ddebe9b 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -15,6 +15,7 @@ The canonical release workflow is .github/workflows/ci.yml. Releases use bare Se
 - 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.
 - Retain the uploaded package, checksum, and provenance artifacts.
 
 ## Publish
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
index 233f522..6249760 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
@@ -116,6 +116,23 @@ public void ReleaseArtifactsMustContainAllThreeVersionedPackages()
                 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");
+        }
+
         private static string ResolveChannel(string tag)
         {
             Assert.IsTrue(TryResolveChannel(tag, out var channel), tag);

From a948f36628bf74cf9afa51df1164e0cb7b3e5663 Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 13:20:14 +0200
Subject: [PATCH 7/8] ci: discover native AOT consumer executable

Co-authored-by: Codex 
---
 .github/workflows/ci.yml                         |  9 ++++++++-
 IMPLEMENTATION_PLAN.md                           |  2 +-
 STATUS.md                                        |  2 +-
 .../ReleaseQualityContractTests.cs               | 16 ++++++++++++++++
 4 files changed, 26 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a1e9c08..4304e1f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -428,7 +428,14 @@ jobs:
           }
           $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/"
-          & (Join-Path $aot 'Consumer.exe')
+          $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@v4
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index a01e694..898a282 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -5,7 +5,7 @@
 - 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. `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 15/15; 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 now use an explicit `win-x64` runtime. `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 is fixed in the pending PR rerun.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 16/16; 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. `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.
 
diff --git a/STATUS.md b/STATUS.md
index 819ca64..533e1d5 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -5,7 +5,7 @@
 - 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, and publishes only from a protected tag or approved production dispatch. 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 15/15; 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 now use an explicit `win-x64` runtime after the first remote run exposed NETSDK1047. `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.
+- Current evidence (2026-09-17): ReleaseQualityContractTests 16/16; 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. `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.
 
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
index 6249760..ebe908c 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
@@ -133,6 +133,22 @@ public void FinalPackageConsumerRestoresWindowsRuntimeBeforeNoRestorePublish()
                 "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));
+        }
+
         private static string ResolveChannel(string tag)
         {
             Assert.IsTrue(TryResolveChannel(tag, out var channel), tag);

From ea7226c4e206fd37f507ad1d3ba5c10a3c8160c6 Mon Sep 17 00:00:00 2001
From: Alessandro Giorgetti 
Date: Thu, 17 Sep 2026 13:56:23 +0200
Subject: [PATCH 8/8] ci: migrate artifact actions to Node 24

Co-authored-by: Codex 
---
 .github/workflows/ci.yml                             |  6 +++---
 IMPLEMENTATION_PLAN.md                               |  4 ++--
 STATUS.md                                            |  4 ++--
 docs/RELEASE_CHECKLIST.md                            |  1 +
 .../ReleaseQualityContractTests.cs                   | 12 ++++++++++++
 5 files changed, 20 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4304e1f..b462c3d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -438,7 +438,7 @@ jobs:
           }
 
       - name: Upload validated packages
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v6
         with:
           name: litemapper-packages-${{ steps.gitversion.outputs.semver }}
           path: ${{ env.PACKAGE_OUTPUT }}
@@ -472,7 +472,7 @@ jobs:
 
     steps:
       - name: Download validated packages
-        uses: actions/download-artifact@v4
+        uses: actions/download-artifact@v7
         with:
           name: litemapper-packages-${{ needs.pack-and-validate.outputs.semver }}
           path: ${{ env.PACKAGE_OUTPUT }}
@@ -544,7 +544,7 @@ jobs:
 
     steps:
       - name: Download verified packages
-        uses: actions/download-artifact@v4
+        uses: actions/download-artifact@v7
         with:
           name: litemapper-packages-${{ needs.pack-and-validate.outputs.semver }}
           path: ${{ env.PACKAGE_OUTPUT }}
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 898a282..976072a 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -3,9 +3,9 @@
 ## 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. `publish-nuget.ps1` enforces the same exact six-artifact and no-duplicate-suppression contract for local releases.
+- 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 16/16; 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. `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 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.
 
diff --git a/STATUS.md b/STATUS.md
index 533e1d5..82ba3b2 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -3,9 +3,9 @@
 ## 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, and publishes only from a protected tag or approved production dispatch. Dry-run follows the same validation and handoff path and never calls `dotnet nuget push`.
+- 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 16/16; 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. `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.
+- 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.
 
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index ddebe9b..b05d24f 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -16,6 +16,7 @@ The canonical release workflow is .github/workflows/ci.yml. Releases use bare Se
 - 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
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
index ebe908c..f64316e 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ReleaseQualityContractTests.cs
@@ -149,6 +149,18 @@ public void FinalPackageConsumerDiscoversTheNativeAotExecutable()
                 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);