diff --git a/src/Runner.Common/ActionResult.cs b/src/Runner.Common/ActionResult.cs index 3992ae1df3e..633f9282632 100644 --- a/src/Runner.Common/ActionResult.cs +++ b/src/Runner.Common/ActionResult.cs @@ -8,6 +8,8 @@ public enum ActionResult Cancelled = 2, - Skipped = 3 + Skipped = 3, + + Neutral = 4 } } diff --git a/src/Runner.Common/Util/TaskResultUtil.cs b/src/Runner.Common/Util/TaskResultUtil.cs index e82bb2896b9..6f77c4856b7 100644 --- a/src/Runner.Common/Util/TaskResultUtil.cs +++ b/src/Runner.Common/Util/TaskResultUtil.cs @@ -71,6 +71,8 @@ public static ActionResult ToActionResult(this TaskResult result) return ActionResult.Cancelled; case TaskResult.Skipped: return ActionResult.Skipped; + case TaskResult.Neutral: + return ActionResult.Neutral; default: throw new NotSupportedException(result.ToString()); } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 8308b434220..cf4ff7f394c 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -285,6 +285,13 @@ private async Task CompleteJobAsync(IRunServer runServer, IExecution jobContext.Debug($"Finishing: {message.JobDisplayName}"); TaskResult result = jobContext.Complete(taskResult); + if (result == TaskResult.Failed && ShouldApplyAllowFailure(jobContext, message)) + { + jobContext.Debug("Job failed but allow-failure is set. Reporting as Neutral."); + result = TaskResult.Neutral; + jobContext.Result = result; + } + var jobQueueTelemetry = await ShutdownQueue(throwOnFailure: false); // include any job telemetry from the background upload process. if (jobQueueTelemetry?.Count > 0) @@ -359,6 +366,13 @@ private async Task CompleteJobAsync(IJobServer jobServer, IExecution jobContext.Debug($"Finishing: {message.JobDisplayName}"); TaskResult result = jobContext.Complete(taskResult); + if (result == TaskResult.Failed && ShouldApplyAllowFailure(jobContext, message)) + { + jobContext.Debug("Job failed but allow-failure is set. Reporting as Neutral."); + result = TaskResult.Neutral; + jobContext.Result = result; + } + if (_runnerSettings.DisableUpdate == true) { await WarningOutdatedRunnerAsync(jobContext, message, result); @@ -441,6 +455,23 @@ private async Task CompleteJobAsync(IJobServer jobServer, IExecution throw new AggregateException(exceptions); } + private bool ShouldApplyAllowFailure(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message) + { + if (message.AllowFailure) + { + return true; + } + + var allowFailureEnv = Environment.GetEnvironmentVariable("ACTIONS_ALLOW_FAILURE"); + if (string.Equals(allowFailureEnv, "true", StringComparison.OrdinalIgnoreCase)) + { + jobContext.Debug("allow-failure detected via ACTIONS_ALLOW_FAILURE environment variable."); + return true; + } + + return false; + } + private void MaskTelemetrySecrets(List jobTelemetry) { foreach (var telemetryItem in jobTelemetry) diff --git a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs index 96cf07a71c2..ebbfc3895e5 100644 --- a/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs +++ b/src/Sdk/DTPipelines/Pipelines/AgentJobRequestMessage.cs @@ -260,6 +260,13 @@ public bool EnableDebugger set; } + [DataMember(EmitDefaultValue = false)] + public bool AllowFailure + { + get; + set; + } + [DataMember(EmitDefaultValue = false)] public DebuggerTunnelInfo DebuggerTunnel { diff --git a/src/Sdk/DTWebApi/WebApi/TaskResult.cs b/src/Sdk/DTWebApi/WebApi/TaskResult.cs index c6367a95370..abbc030cfe2 100644 --- a/src/Sdk/DTWebApi/WebApi/TaskResult.cs +++ b/src/Sdk/DTWebApi/WebApi/TaskResult.cs @@ -22,5 +22,8 @@ public enum TaskResult [EnumMember] Abandoned = 5, + + [EnumMember] + Neutral = 6, } } diff --git a/src/Test/L0/Util/TaskResultUtilL0.cs b/src/Test/L0/Util/TaskResultUtilL0.cs index 331e71555dc..934faf7a81f 100644 --- a/src/Test/L0/Util/TaskResultUtilL0.cs +++ b/src/Test/L0/Util/TaskResultUtilL0.cs @@ -1,4 +1,5 @@ using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using Xunit; @@ -200,6 +201,50 @@ public void TaskResultsMerge() merged = TaskResultUtil.MergeTaskResults(TaskResult.Skipped, TaskResult.Failed); // Actual Assert.Equal(TaskResult.Skipped, merged); + + // + // Neutral is terminal (not overwritten by subsequent results) + // + // Act. + merged = TaskResultUtil.MergeTaskResults(TaskResult.Neutral, TaskResult.Succeeded); + // Actual + Assert.Equal(TaskResult.Neutral, merged); + // Act. + merged = TaskResultUtil.MergeTaskResults(TaskResult.Neutral, TaskResult.Failed); + // Actual + Assert.Equal(TaskResult.Neutral, merged); + // Act. + merged = TaskResultUtil.MergeTaskResults(null, TaskResult.Neutral); + // Actual + Assert.Equal(TaskResult.Neutral, merged); + // Act. + merged = TaskResultUtil.MergeTaskResults(TaskResult.Succeeded, TaskResult.Neutral); + // Actual + Assert.Equal(TaskResult.Neutral, merged); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void TaskResultNeutralReturnCodeTranslate() + { + using (TestHostContext hc = new(this)) + { + TaskResult neutral = TaskResultUtil.TranslateFromReturnCode(TaskResultUtil.TranslateToReturnCode(TaskResult.Neutral)); + Assert.Equal(TaskResult.Neutral, neutral); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void TaskResultNeutralToActionResult() + { + using (TestHostContext hc = new(this)) + { + ActionResult actionResult = TaskResult.Neutral.ToActionResult(); + Assert.Equal(ActionResult.Neutral, actionResult); } } } diff --git a/src/Test/L0/Worker/JobRunnerL0.cs b/src/Test/L0/Worker/JobRunnerL0.cs index e8011b9b051..5355ba79180 100644 --- a/src/Test/L0/Worker/JobRunnerL0.cs +++ b/src/Test/L0/Worker/JobRunnerL0.cs @@ -175,5 +175,84 @@ public async Task WorksWithRunnerJobRequestMessageType() Assert.Equal(TaskResult.Succeeded, _jobEc.Result); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task AllowFailureConvertsFailedToNeutral() + { + using (TestHostContext hc = CreateTestContext()) + { + _jobExtension.Setup(x => x.InitializeJob(It.IsAny(), It.IsAny())) + .Throws(new Exception()); + + var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest); + message.AllowFailure = true; + + await _jobRunner.RunAsync(message, _tokenSource.Token); + + Assert.Equal(TaskResult.Neutral, _jobEc.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task AllowFailureDoesNotAffectSucceeded() + { + using (TestHostContext hc = CreateTestContext()) + { + var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest); + message.AllowFailure = true; + + await _jobRunner.RunAsync(message, _tokenSource.Token); + + Assert.Equal(TaskResult.Succeeded, _jobEc.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task AllowFailureEnvVarConvertsFailedToNeutral() + { + using (TestHostContext hc = CreateTestContext()) + { + _jobExtension.Setup(x => x.InitializeJob(It.IsAny(), It.IsAny())) + .Throws(new Exception()); + + var message = GetMessage(JobRequestMessageTypes.RunnerJobRequest); + + Environment.SetEnvironmentVariable("ACTIONS_ALLOW_FAILURE", "true"); + try + { + await _jobRunner.RunAsync(message, _tokenSource.Token); + Assert.Equal(TaskResult.Neutral, _jobEc.Result); + } + finally + { + Environment.SetEnvironmentVariable("ACTIONS_ALLOW_FAILURE", null); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task AllowFailureWithPipelineAgentJobRequest() + { + using (TestHostContext hc = CreateTestContext()) + { + _jobExtension.Setup(x => x.InitializeJob(It.IsAny(), It.IsAny())) + .Throws(new Exception()); + + var message = GetMessage(JobRequestMessageTypes.PipelineAgentJobRequest); + message.AllowFailure = true; + + await _jobRunner.RunAsync(message, _tokenSource.Token); + + Assert.Equal(TaskResult.Neutral, _jobEc.Result); + } + } } }