diff --git a/PowerForge.PowerShell/Services/ModulePipelineRunner.PublishOperations.cs b/PowerForge.PowerShell/Services/ModulePipelineRunner.PublishOperations.cs index 0684d5d01..24522f2ea 100644 --- a/PowerForge.PowerShell/Services/ModulePipelineRunner.PublishOperations.cs +++ b/PowerForge.PowerShell/Services/ModulePipelineRunner.PublishOperations.cs @@ -124,6 +124,53 @@ private void PreflightSynchronizedPackageGitHubRetrySafety( } } + private void PreflightSynchronizedPackageGitHubConfigurationRetrySafety( + ModulePipelinePlan plan, + ModulePipelineRunState state) + { + if (!ShouldUseSynchronizedReleaseCheckpoint(plan, state)) + return; + + foreach (var segment in plan.ProjectBuilds ?? Array.Empty()) + { + if (segment?.Configuration is null || + !ShouldExecuteProjectBuildPublish(plan, segment, PackageBuildPublishDestination.GitHub)) + { + continue; + } + + var operationKey = CreateProjectBuildPublishOperationFingerprint( + plan, + segment, + PackageBuildPublishDestination.GitHub); + if (ShouldSkipSynchronizedReleaseOperation(state, operationKey)) + continue; + + var configPath = ResolvePackageBuildPath(plan.ProjectRoot, segment.Configuration.ConfigPath); + ValidateCoordinatedProjectBuildGitHubConfigurationRetrySafety( + LoadProjectBuildConfiguration(configPath, segment.Configuration)); + } + + foreach (var segment in plan.PackageBuilds ?? Array.Empty()) + { + if (segment?.Configuration is null || + !ShouldExecutePackageBuildPublish(plan, segment, PackageBuildPublishDestination.GitHub)) + { + continue; + } + + var operationKey = CreatePackageBuildPublishOperationFingerprint( + plan, + segment, + PackageBuildPublishDestination.GitHub); + if (ShouldSkipSynchronizedReleaseOperation(state, operationKey)) + continue; + + ValidateCoordinatedProjectBuildGitHubConfigurationRetrySafety( + MapPackageBuildConfiguration(segment.Configuration, plan.ProjectRoot)); + } + } + private static bool TryGetProjectBuildReleaseForCoordinatedGitHubPreflight( ModulePipelineRunState state, object segment, @@ -153,6 +200,14 @@ private static void ValidateCoordinatedProjectBuildGitHubRetrySafety( throw new InvalidOperationException(retrySafetyError); } + private static void ValidateCoordinatedProjectBuildGitHubConfigurationRetrySafety( + ProjectBuildConfiguration configuration) + { + var retrySafetyError = ProjectBuildGitHubRetrySafety.ValidateConfiguration(configuration); + if (!string.IsNullOrWhiteSpace(retrySafetyError)) + throw new InvalidOperationException(retrySafetyError); + } + private void PreflightSynchronizedModulePublishVersions( ModulePipelinePlan plan, ModulePipelineRunState state, diff --git a/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs b/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs index ffde9ad60..386e6d8b5 100644 --- a/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs +++ b/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs @@ -15,6 +15,7 @@ private void ExecutePreparationAndBuildPhases( { EnterSynchronizedReleaseCheckpointScope(plan, state); RestoreSynchronizedReleaseCheckpoint(plan, state); + PreflightSynchronizedPackageGitHubConfigurationRetrySafety(plan, state); var synchronizeVersionBeforeLifecycleActions = ShouldSynchronizeModuleVersionForRun( plan.Release, plan.GateMode); diff --git a/PowerForge.Tests/ModulePipelineUnifiedReleaseCheckpointTests.cs b/PowerForge.Tests/ModulePipelineUnifiedReleaseCheckpointTests.cs index 4564665ea..5c40914cd 100644 --- a/PowerForge.Tests/ModulePipelineUnifiedReleaseCheckpointTests.cs +++ b/PowerForge.Tests/ModulePipelineUnifiedReleaseCheckpointTests.cs @@ -680,7 +680,8 @@ private static void WriteSynchronizedProjectBuildConfig( bool publishNuGet, bool? skipDuplicate = null, bool publishGitHub = false, - string? gitHubReleaseMode = null) + string? gitHubReleaseMode = null, + string? gitHubTagTemplate = null) { var path = Path.Combine(rootPath, "Build", fileName); Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -690,9 +691,12 @@ private static void WriteSynchronizedProjectBuildConfig( var gitHubReleaseModeProperty = string.IsNullOrWhiteSpace(gitHubReleaseMode) ? string.Empty : $",\"GitHubReleaseMode\":\"{gitHubReleaseMode}\""; + var gitHubTagTemplateProperty = string.IsNullOrWhiteSpace(gitHubTagTemplate) + ? string.Empty + : $",\"GitHubTagTemplate\":\"{gitHubTagTemplate}\""; File.WriteAllText( path, - $"{{\"RootPath\":\"Sources\",\"ExpectedVersionMap\":{{\"{projectName}\":\"2.0.X\"}},\"ExpectedVersionMapAsInclude\":true,\"UpdateVersions\":true,\"Build\":true,\"PublishNuget\":{publishNuGet.ToString().ToLowerInvariant()},\"PublishGitHub\":{publishGitHub.ToString().ToLowerInvariant()}{skipDuplicateProperty}{gitHubReleaseModeProperty}}}"); + $"{{\"RootPath\":\"Sources\",\"ExpectedVersionMap\":{{\"{projectName}\":\"2.0.X\"}},\"ExpectedVersionMapAsInclude\":true,\"UpdateVersions\":true,\"Build\":true,\"PublishNuget\":{publishNuGet.ToString().ToLowerInvariant()},\"PublishGitHub\":{publishGitHub.ToString().ToLowerInvariant()}{skipDuplicateProperty}{gitHubReleaseModeProperty}{gitHubTagTemplateProperty}}}"); } private static ModulePipelineSpec CreateGalleryReleaseSpec( diff --git a/PowerForge.Tests/ModulePipelineUnifiedReleaseRetrySafetyTests.cs b/PowerForge.Tests/ModulePipelineUnifiedReleaseRetrySafetyTests.cs index b828b1d09..5ea7964ac 100644 --- a/PowerForge.Tests/ModulePipelineUnifiedReleaseRetrySafetyTests.cs +++ b/PowerForge.Tests/ModulePipelineUnifiedReleaseRetrySafetyTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json.Nodes; using Xunit; namespace PowerForge.Tests; @@ -10,7 +9,7 @@ namespace PowerForge.Tests; public sealed partial class ModulePipelineUnifiedReleaseTests { [Fact] - public void Run_RejectsUnsafePackageGitHubRetrySettingsBeforeEarlierRemoteDestination() + public void Run_RejectsVolatilePackageGitHubTagBeforePackageBuildOrRemoteDestination() { var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); var stagingPath = Path.Combine(Path.GetTempPath(), "PowerForge.Tests.Staging", Guid.NewGuid().ToString("N")); @@ -24,13 +23,16 @@ public void Run_RejectsUnsafePackageGitHubRetrySettingsBeforeEarlierRemoteDestin moduleName, publishNuGet: true, publishGitHub: true, - gitHubReleaseMode: "PerProject"); + gitHubReleaseMode: "Single", + gitHubTagTemplate: "{Repo}-v{UtcTimestamp}"); var remotePublishRequests = 0; + var packageBuildRequests = 0; var runner = CreateRunner( new FakeHostedOperations(new List()), (request, configuration, configPath) => { + packageBuildRequests++; if (request.PublishNuget == true || request.PublishGitHub == true) { request.RemotePublishAttempted?.Invoke(); @@ -52,14 +54,11 @@ public void Run_RejectsUnsafePackageGitHubRetrySettingsBeforeEarlierRemoteDestin var exception = Assert.Throws(() => runner.Run(spec)); - Assert.Contains("GitHubReleaseMode 'Single'", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GitHubTagTemplate", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("{Repo}-v{PrimaryVersion}", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, remotePublishRequests); - var checkpointPath = Assert.Single(Directory.GetFiles( - GetCoordinatedReleaseCheckpointRoot(root.FullName), - "*.json")); - var checkpoint = JsonNode.Parse(File.ReadAllText(checkpointPath))!; - Assert.Equal(string.Empty, checkpoint["SourceFingerprint"]!.GetValue()); - Assert.Equal(string.Empty, checkpoint["PayloadFingerprint"]!.GetValue()); + Assert.Equal(0, packageBuildRequests); + AssertNoCoordinatedReleaseCheckpoint(root.FullName); WriteSynchronizedProjectBuildConfig( root.FullName, @@ -101,6 +100,62 @@ public void Run_RejectsUnsafePackageGitHubRetrySettingsBeforeEarlierRemoteDestin } } + [Fact] + public void Run_AllowsVolatilePackageGitHubTagWhenBuildGateCannotPublish() + { + var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"))); + var stagingPath = Path.Combine(Path.GetTempPath(), "PowerForge.Tests.Staging", Guid.NewGuid().ToString("N")); + try + { + const string moduleName = "TestModule"; + WriteMinimalModule(root.FullName, moduleName, "2.0.10"); + WriteSynchronizedProjectBuildConfig( + root.FullName, + "project.build.json", + moduleName, + publishNuGet: true, + publishGitHub: true, + gitHubReleaseMode: "Single", + gitHubTagTemplate: "{Repo}-v{UtcTimestamp}"); + + var packageBuildRequests = 0; + var runner = CreateRunner( + new FakeHostedOperations(new List()), + (request, configuration, configPath) => + { + packageBuildRequests++; + Assert.False(request.PublishNuget); + Assert.False(request.PublishGitHub); + return CreateProjectBuildResult( + root.FullName, + moduleName, + "2.0.11", + Path.Combine(root.FullName, "Artifacts", "NuGet"), + request, + configPath, + includePackage: true); + }); + var spec = CreateNuGetOnlyReleaseSpec(root.FullName, stagingPath, moduleName); + spec.Segments = spec.Segments + .Prepend(new ConfigurationGateSegment + { + Configuration = new GateConfiguration { Mode = ConfigurationGateMode.Build } + }) + .ToArray(); + + var result = runner.Run(spec); + + Assert.Equal(ConfigurationGateMode.Build, result.Plan.GateMode); + Assert.Equal(1, packageBuildRequests); + AssertNoCoordinatedReleaseCheckpoint(root.FullName); + } + finally + { + try { root.Delete(recursive: true); } catch { } + try { if (Directory.Exists(stagingPath)) Directory.Delete(stagingPath, recursive: true); } catch { } + } + } + [Fact] public void Run_DefersGitHubRetryPreflightForPackageLaneBuiltAfterModule() { diff --git a/PowerForge/Services/ProjectBuildGitHubRetrySafety.cs b/PowerForge/Services/ProjectBuildGitHubRetrySafety.cs index 8142094c5..d539c44d5 100644 --- a/PowerForge/Services/ProjectBuildGitHubRetrySafety.cs +++ b/PowerForge/Services/ProjectBuildGitHubRetrySafety.cs @@ -14,6 +14,28 @@ internal static class ProjectBuildGitHubRetrySafety if (release is null) throw new ArgumentNullException(nameof(release)); + var configurationError = ValidateConfiguration(configuration); + if (!string.IsNullOrWhiteSpace(configurationError)) + return configurationError; + + if (string.IsNullOrWhiteSpace(configuration.GitHubTagName) && + string.IsNullOrWhiteSpace(ProjectBuildSupportService.ResolveGitHubBaseVersion(configuration, release)) && + UsesBaseVersion(configuration.GitHubTagTemplate)) + { + return "Coordinated GitHub publishing requires GitHubTagName, GitHubPrimaryProject, or a stable GitHubTagTemplate that does not depend on {Version}/{PrimaryVersion} when the planned packages have no single base version. Otherwise the publisher falls back to a date-based tag that cannot be resumed safely."; + } + + return null; + } + + /// + /// Validates retry-safety rules that depend only on configuration and can run before package build work begins. + /// + internal static string? ValidateConfiguration(ProjectBuildConfiguration configuration) + { + if (configuration is null) + throw new ArgumentNullException(nameof(configuration)); + var normalizedReleaseMode = string.IsNullOrWhiteSpace(configuration.GitHubReleaseMode) ? "Single" : configuration.GitHubReleaseMode!.Trim(); @@ -37,14 +59,7 @@ internal static class ProjectBuildGitHubRetrySafety if (string.IsNullOrWhiteSpace(configuration.GitHubTagName) && HasVolatileTagTemplate(configuration.GitHubTagTemplate)) { - return "Coordinated GitHub publishing requires a stable GitHub tag; timestamp tokens are not retry-safe unless GitHubTagName fixes the exact tag."; - } - - if (string.IsNullOrWhiteSpace(configuration.GitHubTagName) && - string.IsNullOrWhiteSpace(ProjectBuildSupportService.ResolveGitHubBaseVersion(configuration, release)) && - UsesBaseVersion(configuration.GitHubTagTemplate)) - { - return "Coordinated GitHub publishing requires GitHubTagName, GitHubPrimaryProject, or a stable GitHubTagTemplate that does not depend on {Version}/{PrimaryVersion} when the planned packages have no single base version. Otherwise the publisher falls back to a date-based tag that cannot be resumed safely."; + return "Coordinated GitHub publishing requires a stable GitHub tag. GitHubTagTemplate contains date or timestamp tokens, so a retry would target a different release. Set GitHubTagName to the exact tag or use a version-based GitHubTagTemplate such as '{Repo}-v{PrimaryVersion}'."; } return null;