Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigurationProjectBuildSegment>())
{
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<ConfigurationPackageBuildSegment>())
{
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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ private void ExecutePreparationAndBuildPhases(
{
EnterSynchronizedReleaseCheckpointScope(plan, state);
RestoreSynchronizedReleaseCheckpoint(plan, state);
PreflightSynchronizedPackageGitHubConfigurationRetrySafety(plan, state);
var synchronizeVersionBeforeLifecycleActions = ShouldSynchronizeModuleVersionForRun(
plan.Release,
plan.GateMode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)!);
Expand All @@ -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(
Expand Down
75 changes: 65 additions & 10 deletions PowerForge.Tests/ModulePipelineUnifiedReleaseRetrySafetyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json.Nodes;
using Xunit;

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"));
Expand All @@ -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<string>()),
(request, configuration, configPath) =>
{
packageBuildRequests++;
if (request.PublishNuget == true || request.PublishGitHub == true)
{
request.RemotePublishAttempted?.Invoke();
Expand All @@ -52,14 +54,11 @@ public void Run_RejectsUnsafePackageGitHubRetrySettingsBeforeEarlierRemoteDestin

var exception = Assert.Throws<InvalidOperationException>(() => 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<string>());
Assert.Equal(string.Empty, checkpoint["PayloadFingerprint"]!.GetValue<string>());
Assert.Equal(0, packageBuildRequests);
AssertNoCoordinatedReleaseCheckpoint(root.FullName);

WriteSynchronizedProjectBuildConfig(
root.FullName,
Expand Down Expand Up @@ -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<string>()),
(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()
{
Expand Down
31 changes: 23 additions & 8 deletions PowerForge/Services/ProjectBuildGitHubRetrySafety.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// Validates retry-safety rules that depend only on configuration and can run before package build work begins.
/// </summary>
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();
Expand All @@ -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;
Expand Down
Loading