Add opt-out for publish-on-validation-error failure (3.8)

This forward-ports the FailOnValidationErrors publish toggle to the 3.8
mainline. It is purely additive and changes no default behavior: publishing
a workflow definition still fails when validation errors are present
(FailOnValidationErrors defaults to true, i.e. strict = opt-out).

Setting FailOnValidationErrors to false — via
WorkflowManagementFeature.UseFailOnValidationErrors(false) or the
ManagementOptions.FailOnValidationErrors option — allows publication to
succeed while surfacing the validation errors as warnings on the publish
result. This enables publishing workflows that intentionally leave required
properties blank (for example, an empty Cron expression used to disable a
trigger).

This is the 3.8 counterpart to #7740, which introduces the same toggle as
opt-in (default false) on the 3.6.x line. References #7738.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2026-06-19 21:03:10 +02:00
parent f74087ea8b
commit 7981892838
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
5 changed files with 96 additions and 3 deletions

View file

@ -61,6 +61,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
private string CompressionAlgorithm { get; set; } = nameof(None);
private LogPersistenceMode LogPersistenceMode { get; set; } = LogPersistenceMode.Include;
private bool IsReadOnlyMode { get; set; }
private bool FailOnValidationErrors { get; set; } = true;
/// <summary>
/// A set of activity types to make available to the system.
@ -219,6 +220,17 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
return this;
}
/// <summary>
/// Enables or disables failing workflow publication when the workflow has validation errors.
/// Defaults to <c>true</c> as of 3.8.0 (publication fails when validation errors are present).
/// Set to <c>false</c> to allow publication to succeed, returning validation errors as warnings.
/// </summary>
public WorkflowManagementFeature UseFailOnValidationErrors(bool enabled = true)
{
FailOnValidationErrors = enabled;
return this;
}
public WorkflowManagementFeature UseWorkflowDefinitionPublisher(Func<IServiceProvider, IWorkflowDefinitionPublisher> workflowDefinitionPublisher)
{
_workflowDefinitionPublisher = workflowDefinitionPublisher;
@ -313,6 +325,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
options.CompressionAlgorithm = CompressionAlgorithm;
options.LogPersistenceMode = LogPersistenceMode;
options.IsReadOnlyMode = IsReadOnlyMode;
options.FailOnValidationErrors = FailOnValidationErrors;
});
Services.Configure<ExpressionOptions>(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext)));

View file

@ -32,4 +32,14 @@ public class ManagementOptions
/// A mode that does not allow editing workflows.
/// </summary>
public bool IsReadOnlyMode { get; set; }
/// <summary>
/// Determines whether publishing a workflow definition fails when the workflow has validation errors.
/// When <c>true</c> (the default as of 3.8.0), publishing fails if any validation errors are present.
/// When <c>false</c>, publishing is allowed to succeed and any validation errors are returned as warnings
/// on the publish result. This allows publishing workflows that intentionally leave required properties
/// blank (for example, an empty Cron expression used to disable a trigger). In 3.6.x and 3.7.x this
/// defaulted to <c>false</c> (opt-in); as of 3.8.0 it defaults to <c>true</c> (opt-out).
/// </summary>
public bool FailOnValidationErrors { get; set; } = true;
}

View file

@ -8,7 +8,9 @@ using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Materializers;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Management.Options;
using Elsa.Workflows.Models;
using Microsoft.Extensions.Options;
namespace Elsa.Workflows.Management.Services;
@ -20,7 +22,8 @@ public class WorkflowDefinitionPublisher(
IIdentityGenerator identityGenerator,
IActivitySerializer activitySerializer,
IMediator mediator,
ISystemClock systemClock)
ISystemClock systemClock,
IOptions<ManagementOptions> options)
: IWorkflowDefinitionPublisher
{
/// <inheritdoc />
@ -87,7 +90,7 @@ public class WorkflowDefinitionPublisher(
var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken);
var validationErrors = (await workflowValidator.ValidateAsync(workflowGraph.Workflow, cancellationToken)).ToList();
if (validationErrors.Any())
if (validationErrors.Any() && options.Value.FailOnValidationErrors)
return new(false, validationErrors, new([]));
await mediator.SendAsync(new WorkflowDefinitionPublishing(definition), cancellationToken);

View file

@ -1,4 +1,5 @@
using Elsa.Testing.Shared;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.Management;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
@ -8,10 +9,12 @@ namespace Elsa.Workflows.IntegrationTests.Scenarios.ImportAndPublish;
public class ImportAndPublishCronTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly ITestOutputHelper _testOutputHelper;
private readonly IServiceProvider _services;
public ImportAndPublishCronTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.Build();
@ -53,4 +56,29 @@ public class ImportAndPublishCronTests
Assert.Single(result.ValidationErrors);
Assert.Equal("Error when parsing cron expression: The given cron expression has an invalid format. Seconds: Value must be a number between 0 and 59 (all inclusive).", result.ValidationErrors.Single().Message);
}
[Fact(DisplayName = "Cron workflow with bad cron expression should publish successfully when FailOnValidationErrors is disabled.")]
public async Task ImportAndPublish_ShouldSucceed_WithBadCronExpression_WhenFailOnValidationErrorsDisabled()
{
// Opt out of strict publishing.
var services = new TestApplicationBuilder(_testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.ConfigureElsa(elsa => elsa.UseWorkflowManagement(management => management.UseFailOnValidationErrors(false)))
.Build();
// Populate registries.
await services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/bad-cron-expression.json");
// Publish.
IWorkflowDefinitionPublisher workflowDefinitionPublisher = services.GetRequiredService<IWorkflowDefinitionPublisher>();
var result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition);
// Assert: publishing succeeds while the validation error is surfaced as a warning.
Assert.True(result.Succeeded);
Assert.Single(result.ValidationErrors);
Assert.Equal("Error when parsing cron expression: The given cron expression has an invalid format. Seconds: Value must be a number between 0 and 59 (all inclusive).", result.ValidationErrors.Single().Message);
}
}

View file

@ -9,10 +9,12 @@ namespace Elsa.Workflows.IntegrationTests.Scenarios.ImportAndPublish;
public class ImportAndPublishHttpEndpointsTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly ITestOutputHelper _testOutputHelper;
private readonly IServiceProvider _services;
public ImportAndPublishHttpEndpointsTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.ConfigureElsa(configure => configure.UseHttp())
@ -65,4 +67,41 @@ public class ImportAndPublishHttpEndpointsTests
Assert.Single(result.ValidationErrors);
Assert.Equal("The /test path and get method are already in use by another workflow!", result.ValidationErrors.Single().Message);
}
[Fact(DisplayName = "Http endpoint workflow with duplicate path and method should publish successfully when FailOnValidationErrors is disabled.")]
public async Task ImportAndPublish_ShouldSucceed_WithTwoHttpEndpointSamePathMethod_WhenFailOnValidationErrorsDisabled()
{
// Opt out of strict publishing.
var services = new TestApplicationBuilder(_testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.ConfigureElsa(configure => configure
.UseHttp()
.UseWorkflowManagement(management => management.UseFailOnValidationErrors(false)))
.Build();
// Populate registries.
await services.PopulateRegistriesAsync();
// Import first workflow.
var workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/http-workflow.json");
// Publish first workflow.
IWorkflowDefinitionPublisher workflowDefinitionPublisher = services.GetRequiredService<IWorkflowDefinitionPublisher>();
var result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition);
// Assert first workflow.
Assert.True(result.Succeeded);
Assert.Empty(result.ValidationErrors);
// Import second workflow.
workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/http-workflow.json");
// Publish second workflow.
result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition);
// Assert: publishing succeeds while the validation error is surfaced as a warning.
Assert.True(result.Succeeded);
Assert.Single(result.ValidationErrors);
Assert.Equal("The /test path and get method are already in use by another workflow!", result.ValidationErrors.Single().Message);
}
}