diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ffb6e7..7986544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -532,7 +532,7 @@ jobs: runs-on: windows-latest environment: production if: > - github.ref_type == 'tag' || + (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.publish == true && diff --git a/CHANGELOG.md b/CHANGELOG.md index 913e414..9213cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +## Fixes + +- Issue #19: tag-selected manual workflow dry runs no longer enter the NuGet publish job. Automatic tag publishing requires a push event; manual branch publishing retains its explicit publish and dry-run gates. [#19](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/19) + ## 3.0.0 ### Breaking Changes diff --git a/DECISIONS.md b/DECISIONS.md index 0b5690e..3c9250a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,13 @@ `SPECIFICATION.md` is authoritative. Accepted entries here are non-semantic implementation decisions unless explicitly stated otherwise. +## Issue #19: distinguish automatic tag pushes from manual dispatches (2026-10-03) + +- Context: the bare tag test in the publish condition bypassed workflow_dispatch inputs. With successful prerequisites, a tag-selected dry run could enter the production approval gate even with publish=false. +- Decision: automatic tag publishing requires github.event_name=push. The existing manual branch publishing gate remains publish=true, dry_run=false on main/release/*/hotfix/*. Tag dispatches remain available for nonpublishing rehearsals; no new manual tag publishing path is introduced. +- Evidence: MSTest evaluates the actual workflow conditions and needs graph over 96 event/ref/input cases and 48 unsuccessful-prerequisite cases. Two tag-rehearsal cases verify zero mocked push calls; five authorized-path cases execute the extracted publish script with dotnet mocked locally; unsupported-event and absent-input controls also run. +- Consequences: one production YAML line changes; production environment protection, exact package/symbol validation, checksums/provenance, and NuGet push behavior remain intact. The restricted expression evaluator and local job scheduler validate current source semantics, not live GitHub runner/environment behavior. This is an operational release-safety fix, not a product-specification change. + ## Accepted non-semantic implementation decisions ### DEC-0001 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 976072a..1dbc57c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #19: tag-selected rehearsal publishing gate (2026-10-03) + +- Scope: prevent tag-selected workflow dispatches from bypassing manual publish/dry_run authorization. No generator, public API, package validation, or release-protection change. +- Test-first evidence: source-extracted condition/dependency tests and mocked execution of the actual push step initially produced 20 failures and 134 passes, with zero skips. Both dry-run tag dispatches reached three mocked pushes; non-dry-run tag dispatches already skipped pack. +- Implementation: require a push event for the automatic tag branch of the publish condition. Preserve manual production publishing on main, release/*, and hotfix/* with publish=true and dry_run=false, and preserve production approval and the successful artifact dependency chain. +- Validation: the 154 new cases and 19 existing release-quality cases pass; Release solution build has zero warnings/errors. Full local suite results are recorded in STATUS.md; exact-head ordinary PR CI results are recorded in the PR and issue #19. No release-capable workflow is dispatched and no NuGet publication is attempted. + ## 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. diff --git a/STATUS.md b/STATUS.md index 82ba3b2..69c3179 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,16 @@ # Mammoth.LiteMapper Status +## GitHub issue #19 publishing gate checkpoint (2026-10-03) + +- Scope: prevent tag-selected manual rehearsals from entering the NuGet publish job. The production change requires a push event for automatic tag publishing; manual main/release/*/hotfix/* publishing still requires publish=true and dry_run=false. Production approval, dependencies, and all package/artifact validations are preserved. +- Red-first evidence: 154 new source-based condition/dependency/mock cases ran before the YAML change: 20 failed, 134 passed, zero skipped. Both tag-selected dry runs recorded three mocked push calls, including publish=false. Non-dry-run tag dispatches already skipped pack, despite their unsafe raw publish condition. +- Focused evidence: 154 new cases plus 19 existing release-quality cases pass (173 total, zero skipped). Release solution build passes with zero warnings/errors. YAML parsing and whitespace validation allowing the repository's existing CRLF endings pass. Independent read-only review found no acceptance gap. +- Full local evidence: the solution run recorded 806 passes, two sample/usage failures caused by stale NuGet assets pointing to the sandbox user's cache (NETSDK1064 for System.IO.Hashing 10.0.12), and one Native AOT prerequisite skip. Regenerating assets in the working environment and rebuilding recovered both failures; the affected tests plus all focused release tests then passed 175/175 with zero skips. Across the full run and targeted recovery, all 808 executed test cases have passing evidence; this does not claim a single all-green full-suite invocation. +- Local limitation: MSVC C++ tooling is unavailable, so NativeAotConsumerPublishesAndRunsWithoutLiteMapperWarnings is inconclusive. Trimming and the other package/consumer/API/benchmark validations passed in the full run. The initial sandbox restore failed with NU1301 SSL authentication errors; the approved unrestricted restore succeeded. +- Review handoff: draft PR targets develop. Exact-head ordinary PR CI results are recorded in the PR and issue #19. Local evidence is under artifacts/validation/issue-19/{red,focused,full,recovery}. The condition evaluator covers the workflow's current expression subset and needs graph, not live GitHub production-environment approval behavior. +- Operational note: installed AgentStack expects .agent-stack/active-tracker.json while this repository uses .agent-stack/modules/protocol/active-tracker.json; repository-authorized GitHub CLI fallback recorded the plan and significant checkpoints. GITHUB_PrimordialCode authentication remained process-local. +- Boundaries: no workflow dispatch, real NuGet push, release, tag push, merge, or security-setting change. Issue #19 remains open for human review; no unrelated issue or product contract changed. + ## 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. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 08fecaf..358590f 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -6,7 +6,7 @@ The canonical release workflow is .github/workflows/ci.yml. Releases use bare Se - 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. +- Run the workflow manually with dry_run=true on a branch or tag to rehearse the complete validation path. The publish job must remain skipped, regardless of the publish input. ## Validation and artifacts @@ -21,6 +21,6 @@ The canonical release workflow is .github/workflows/ci.yml. Releases use bare Se ## Publish -- Publish only from a valid SemVer tag or an explicitly approved production workflow dispatch on main, release/**, or hotfix/**. +- Publish automatically only on a push of a valid SemVer tag. Manual production publishing requires publish=true and dry_run=false on main, release/**, or hotfix/**; selecting a tag for a manual dispatch does not authorize publishing. - 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/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs new file mode 100644 index 0000000..06edc05 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowPublishingConditionTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Packaging.Tests +{ + [TestClass] + public sealed class WorkflowPublishingConditionTests + { + public static IEnumerable PublishingCases() + { + foreach (var eventName in new[] { "push", "pull_request", "workflow_dispatch" }) + foreach (var reference in new[] + { + "refs/tags/1.2.3", "refs/tags/1.2.3-beta.1", "refs/heads/main", + "refs/heads/release/1.2", "refs/heads/hotfix/1.2.3", "refs/heads/develop", + "refs/heads/feature/test", "refs/heads/release-candidate" + }) + foreach (var publish in new[] { false, true }) + foreach (var dryRun in new[] { false, true }) + yield return new object[] { eventName, reference, publish, dryRun }; + } + + [TestMethod] + [DynamicData(nameof(PublishingCases))] + public void PublishingRequiresAnAuthorizedEventAndSuccessfulArtifactHandoff( + string eventName, string reference, bool publish, bool dryRun) + { + var workflow = Workflow.Read(); + var context = Context(eventName, reference, publish, dryRun); + var tagPush = eventName == "push" && reference.StartsWith("refs/tags/", StringComparison.Ordinal); + var manualPublish = eventName == "workflow_dispatch" && publish && !dryRun && + (reference == "refs/heads/main" || reference.StartsWith("refs/heads/release/", StringComparison.Ordinal) || + reference.StartsWith("refs/heads/hotfix/", StringComparison.Ordinal)); + var expectedPublish = tagPush || manualPublish; + var expectedPack = tagPush || manualPublish || (eventName == "workflow_dispatch" && dryRun); + + // Check both the source condition and the source needs graph. A skipped pack can + // conceal an unsafe publish expression, as it did for non-dry-run tag dispatches. + Assert.AreEqual(expectedPublish, workflow.Condition("publish", context), "Raw publish condition."); + var results = workflow.Schedule(context); + Assert.AreEqual(expectedPack ? "success" : "skipped", results["pack-and-validate"]); + Assert.AreEqual(expectedPack ? "success" : "skipped", results["verify-release-artifacts"]); + Assert.AreEqual(expectedPublish ? "success" : "skipped", results["publish"]); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void TagSelectedRehearsalVerifiesArtifactsWithoutCallingNuGetPush(bool publish) + { + var workflow = Workflow.Read(); + var results = workflow.Schedule(Context("workflow_dispatch", "refs/tags/1.2.3", publish, true)); + Assert.AreEqual("success", results["pack-and-validate"]); + Assert.AreEqual("success", results["verify-release-artifacts"]); + var calls = RunMockPublish(workflow, results["publish"] == "success"); + Assert.AreEqual(0, calls.Length, "A tag-selected dry run invoked the workflow's publish command."); + Assert.AreEqual("skipped", results["publish"], "Dry runs must not reach the production approval gate."); + } + + public static IEnumerable UnsuccessfulPrerequisites() + { + foreach (var reference in new[] { "refs/tags/1.2.3", "refs/heads/main", "refs/heads/release/1.2", "refs/heads/hotfix/1.2.3" }) + foreach (var job in new[] { "build-and-test", "roslyn-hosts", "pack-and-validate", "verify-release-artifacts" }) + foreach (var result in new[] { "failure", "cancelled", "skipped" }) + yield return new object[] { reference, job, result }; + } + + [TestMethod] + [DynamicData(nameof(UnsuccessfulPrerequisites))] + public void UnsuccessfulPrerequisitesBlockPublishing(string reference, string job, string result) + { + var workflow = Workflow.Read(); + var eventName = reference.StartsWith("refs/tags/", StringComparison.Ordinal) ? "push" : "workflow_dispatch"; + var context = Context(eventName, reference, true, false); + Assert.AreEqual("success", workflow.Schedule(context)["publish"], "The control must reach publishing."); + var results = workflow.Schedule(context, new Dictionary { [job] = result }); + Assert.AreEqual("skipped", results["publish"], job + "=" + result); + } + + [TestMethod] + [DataRow("push", "refs/tags/1.2.3")] + [DataRow("push", "refs/tags/1.2.3-beta.1")] + [DataRow("workflow_dispatch", "refs/heads/main")] + [DataRow("workflow_dispatch", "refs/heads/release/1.2")] + [DataRow("workflow_dispatch", "refs/heads/hotfix/1.2.3")] + public void AuthorizedPublishingUsesTheExactThreePackagesWithMockedDotnet(string eventName, string reference) + { + var workflow = Workflow.Read(); + var results = workflow.Schedule(Context(eventName, reference, true, false)); + Assert.AreEqual("success", results["publish"]); + var calls = RunMockPublish(workflow, true); + CollectionAssert.AreEqual(new[] + { + "nuget push artifacts/packages/Mammoth.LiteMapper.Abstractions.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key", + "nuget push artifacts/packages/Mammoth.LiteMapper.Generator.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key", + "nuget push artifacts/packages/Mammoth.LiteMapper.1.2.3.nupkg --source https://api.nuget.org/v3/index.json --api-key local-mock-key" + }, calls.Select(call => call.Replace('\\', '/')).ToArray()); + StringAssert.Contains(workflow.Job("publish"), " environment: production"); + } + + [TestMethod] + [DataRow("schedule")] + [DataRow("workflow_run")] + public void OtherEventsCannotAuthorizeTagPublishing(string eventName) + { + var workflow = Workflow.Read(); + var context = Context(eventName, "refs/tags/1.2.3", true, false); + Assert.IsFalse(workflow.Condition("publish", context)); + Assert.AreEqual("skipped", workflow.Schedule(context)["publish"]); + } + + [TestMethod] + public void NonManualEventsHaveNoPublishingInputs() + { + var context = Context("push", "refs/tags/1.2.3", false, true); + context["inputs.publish"] = null; + context["inputs.dry_run"] = null; + Assert.AreEqual("success", Workflow.Read().Schedule(context)["publish"]); + context["github.event_name"] = "pull_request"; + Assert.AreEqual("skipped", Workflow.Read().Schedule(context)["publish"]); + } + + private static Dictionary Context(string eventName, string reference, bool publish, bool dryRun) + { + return new Dictionary + { + ["github.event_name"] = eventName, + ["github.ref"] = reference, + ["github.ref_type"] = reference.StartsWith("refs/tags/", StringComparison.Ordinal) ? "tag" : "branch", + ["inputs.publish"] = publish, + ["inputs.dry_run"] = dryRun + }; + } + + private static string[] RunMockPublish(Workflow workflow, bool scheduled) + { + // Run only the extracted push script, with dotnet shadowed by a local function. + // The genuine dotnet executable and any actual API key are never used here. + var command = @" +$ErrorActionPreference = 'Stop' +$env:NUGET_API_KEY = 'local-mock-key' +function dotnet { 'MOCK:' + ($args -join ' ') } +"; + if (scheduled) + command += workflow.PublishScript + .Replace("${{ needs.pack-and-validate.outputs.semver }}", "1.2.3", StringComparison.Ordinal) + .Replace("${{ env.PACKAGE_OUTPUT }}", "artifacts/packages", StringComparison.Ordinal); + Assert.IsFalse(command.Contains("${{", StringComparison.Ordinal), "Unresolved workflow interpolation."); + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + var result = TestProcess.Run("pwsh", "-NoProfile -EncodedCommand " + encoded, Repository.Root, TimeSpan.FromMinutes(1)); + Assert.AreEqual(0, result.ExitCode, result.Output + result.Error); + return result.Output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith("MOCK:", StringComparison.Ordinal)).Select(line => line.Substring(5)).ToArray(); + } + + private sealed class Workflow + { + private readonly Dictionary jobs; + private Workflow(string source) + { + var jobStart = Regex.Match(source, @"(?m)^jobs:\r?$"); + Assert.IsTrue(jobStart.Success, "Workflow jobs missing."); + jobs = Regex.Matches(source.Substring(jobStart.Index + jobStart.Length), @"(?ms)^ (?[a-z][a-z0-9-]*):\r?\n(?.*?)(?=^ [a-z][a-z0-9-]*:|\z)") + .ToDictionary(match => match.Groups["id"].Value, match => match.Groups["body"].Value); + foreach (var id in new[] { "build-and-test", "roslyn-hosts", "pack-and-validate", "verify-release-artifacts", "publish" }) + Assert.IsTrue(jobs.ContainsKey(id), "Workflow job missing: " + id); + } + + public static Workflow Read() => new Workflow(File.ReadAllText(Repository.Path(".github/workflows/ci.yml"))); + public string Job(string id) => jobs[id]; + public string PublishScript + { + get + { + var match = Regex.Match(Job("publish"), @"(?ms)^ - name: Publish exact packages\r?\n.*?^ run: \|\r?\n(?