From 67ba8a7d429d953396c08550d8590af9889c4efe Mon Sep 17 00:00:00 2001 From: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com> Date: Fri, 29 Oct 2021 15:45:42 +0200 Subject: [PATCH] Support Conditional Steps in Composite Actions (#1438) * conditional support for composite actions * Fix Conditional function evaluation * Push launch.json temporarily * Revert "Push launch.json temporarily" * rename context * Cleanup comments * fix success/failure functions to run based on pre/main steps * idea of step_status * change to use steps context, WIP * add inputs to possible if condition expressions * use action_status * pr cleanup * Added right stages * Test on stage in conditional functions * Fix naming and formatting * Fix tests * Add success and failure L0s * Remove comment * Remove whitespace * Undo formatting * Add L0 for step-if parsing * Add ADR Co-authored-by: Thomas Boop --- docs/adrs/1438-conditional-composite.md | 71 +++++++ src/Runner.Worker/ActionManager.cs | 1 - src/Runner.Worker/ExecutionContext.cs | 15 +- .../Expressions/FailureFunction.cs | 15 +- .../Expressions/SuccessFunction.cs | 15 +- .../Handlers/CompositeActionHandler.cs | 176 +++++++++--------- src/Runner.Worker/JobExtension.cs | 10 +- src/Runner.Worker/action_yaml.json | 20 ++ .../PipelineTemplateConverter.cs | 1 + src/Test/L0/Worker/ActionManifestManagerL0.cs | 26 +++ src/Test/L0/Worker/ExecutionContextL0.cs | 8 +- .../Expressions/ConditionFunctionsL0.cs | 65 ++++++- .../TestData/conditional_composite_action.yml | 49 +++++ 13 files changed, 358 insertions(+), 114 deletions(-) create mode 100644 docs/adrs/1438-conditional-composite.md create mode 100644 src/Test/TestData/conditional_composite_action.yml diff --git a/docs/adrs/1438-conditional-composite.md b/docs/adrs/1438-conditional-composite.md new file mode 100644 index 00000000000..c95405697f2 --- /dev/null +++ b/docs/adrs/1438-conditional-composite.md @@ -0,0 +1,71 @@ +# ADR 1438: Support Conditionals In Composite Actions + +**Date**: 2021-10-13 + +**Status**: Accepted + +## Context + +We recently shipped composite actions, which allows you to reuse individual steps inside an action. +However, one of the [most requested features](https://github.com/actions/runner/issues/834) has been a way to support the `if` keyword. + +### Goals +- We want to keep consistent with current behavior +- We want to support conditionals via the `if` keyword +- Our built in functions like `success` should be implementable without calling them, for example you can do `job.status == success` rather then `success()` currently. + +### How does composite currently work? + +Currently, we have limited conditional support in composite actions for `pre` and `post` steps. +These are based on the `job status`, and support keywords like `always()`, `failed()`, `success()` and `cancelled()`. +However, generic or main steps do **not** support conditionals. + +By default, in a regular workflow, a step runs on the `success()` condition. Which looks at the **job** **status**, sees if it is successful and runs. + +By default, in a composite action, main steps run until a single step fails in that composite action, then the composite action is halted early. It does **not** care about the job status. +Pre, and post steps in composite actions use the job status to determine if they should run. + +### How do we go forward? + +Well, if we think about what composite actions are currently doing when invoking main steps, they are checking if the current composite action is successful. +Lets formalize that concept into a "real" idea. + +- We will add an `action_status` field to the github context to mimic the [job's context status](https://docs.github.com/en/actions/learn-github-actions/contexts#job-context). + - We have an existing concept that does this `action_path` which is only set for composite actions on the github context. +- In a composite action during a main step, the `success()` function will check if `action_status == success`, rather then `job_status == success`. Failure will work the same way. + - Pre and post steps in composite actions will not change, they will continue to check the job status. + + +### Nested Scenario +For nested composite actions, we will follow the existing behavior, you only care about your current composite action, not any parents. +For example, lets imagine a scenario with a simple nested composite action + +``` +- Job + - Regular Step + - Composite Action + - runs: exit 1 + - if: always() + uses: A child composite action + - if: success() + runs: echo "this should print" + - runs: echo "this should also print" + - if: success() + runs: echo "this will not print as the current composite action has failed already" + +``` +The child composite actions steps should run in this example, the child composite action has not yet failed, so it should run all steps until a step fails. This is consistent with how a composite action currently works in production if the main job fails but a composite action is invoked with `if:always()` or `if: failure()` + +### Other options explored +We could add the `current_step_status` to the job context rather then `__status` to the steps context, however this comes with two major downsides: +- We need to support the field for every type of step, because its non trivial to remove a field from the job context once it has been added (its readonly) + - For all actions besides composite it would only every be `success` + - Its weird to have a `current_step` value on the job context +- We also explored a `__status` on the steps context. + - The `__` is required to prevent us from colliding with a step with id: status + - This felt wrong because the naming was not smooth, and did not fit into current conventions. + +### Consequences +- github context has a new field for the status of the current composite action. +- We support conditional's in composite actions +- We keep the existing behavior for all users, but allow them to expand that functionality. diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 65b47d3f176..d228233bdb7 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -1047,7 +1047,6 @@ private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext execution } } - // TODO: remove once we remove the DistributedTask.EnableCompositeActions FF foreach (var step in compositeAction.Steps) { if (string.IsNullOrEmpty(executionContext.Global.Variables.Get("DistributedTask.EnableCompositeActions")) && step.Reference.Type != Pipelines.ActionSourceType.Script) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index d7bf42249cd..29758746670 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -40,6 +40,7 @@ public interface IExecutionContext : IRunnerService string ScopeName { get; } string SiblingScopeName { get; } string ContextName { get; } + ActionRunStage Stage { get; } Task ForceCompleted { get; } TaskResult? Result { get; set; } TaskResult? Outcome { get; set; } @@ -76,8 +77,8 @@ public interface IExecutionContext : IRunnerService // Initialize void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token); void CancelToken(); - IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, Dictionary intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null); - IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, Dictionary intraActionState = null, string siblingScopeName = null); + IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null); + IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary intraActionState = null, string siblingScopeName = null); // logging long Write(string tag, string message); @@ -144,6 +145,7 @@ public sealed class ExecutionContext : RunnerService, IExecutionContext public string ScopeName { get; private set; } public string SiblingScopeName { get; private set; } public string ContextName { get; private set; } + public ActionRunStage Stage { get; private set; } public Task ForceCompleted => _forceCompleted.Task; public CancellationToken CancellationToken => _cancellationTokenSource.Token; public Dictionary IntraActionState { get; private set; } @@ -292,7 +294,7 @@ public void RegisterPostJobStep(IStep step) Root.PostJobSteps.Push(step); } - public IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, Dictionary intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null) + public IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null) { Trace.Entering(); @@ -301,6 +303,7 @@ public void RegisterPostJobStep(IStep step) child.Global = Global; child.ScopeName = scopeName; child.ContextName = contextName; + child.Stage = stage; child.EmbeddedId = embeddedId; child.SiblingScopeName = siblingScopeName; child.JobTelemetry = JobTelemetry; @@ -351,9 +354,9 @@ public void RegisterPostJobStep(IStep step) /// An embedded execution context shares the same record ID, record name, logger, /// and a linked cancellation token. /// - public IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, Dictionary intraActionState = null, string siblingScopeName = null) + public IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary intraActionState = null, string siblingScopeName = null) { - return Root.CreateChild(_record.Id, _record.Name, _record.Id.ToString("N"), scopeName, contextName, logger: _logger, isEmbedded: true, cancellationTokenSource: CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token), intraActionState: intraActionState, embeddedId: embeddedId, siblingScopeName: siblingScopeName); + return Root.CreateChild(_record.Id, _record.Name, _record.Id.ToString("N"), scopeName, contextName, stage, logger: _logger, isEmbedded: true, cancellationTokenSource: CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token), intraActionState: intraActionState, embeddedId: embeddedId, siblingScopeName: siblingScopeName); } public void Start(string currentOperation = null) @@ -944,7 +947,7 @@ private IExecutionContext CreatePostChild(string displayName, Dictionary(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success; + return actionStatus == ActionResult.Failure; + } + else + { + ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; + return jobStatus == ActionResult.Failure; + } } } } diff --git a/src/Runner.Worker/Expressions/SuccessFunction.cs b/src/Runner.Worker/Expressions/SuccessFunction.cs index 3d161abb55a..6fcc41b799c 100644 --- a/src/Runner.Worker/Expressions/SuccessFunction.cs +++ b/src/Runner.Worker/Expressions/SuccessFunction.cs @@ -24,8 +24,19 @@ protected sealed override object EvaluateCore(EvaluationContext evaluationContex ArgUtil.NotNull(templateContext, nameof(templateContext)); var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext; ArgUtil.NotNull(executionContext, nameof(executionContext)); - ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; - return jobStatus == ActionResult.Success; + + // Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps + var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main; + if (isCompositeMainStep) + { + ActionResult actionStatus = EnumUtil.TryParse(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success; + return actionStatus == ActionResult.Success; + } + else + { + ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; + return jobStatus == ActionResult.Success; + } } } } diff --git a/src/Runner.Worker/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index 4e8284e44c6..4f1c2123426 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -125,7 +125,7 @@ public async Task RunAsync(ActionRunStage stage) { ArgUtil.NotNull(step, step.DisplayName); var stepId = $"__{Guid.NewGuid()}"; - step.ExecutionContext = ExecutionContext.CreateEmbeddedChild(childScopeName, stepId, Guid.NewGuid()); + step.ExecutionContext = ExecutionContext.CreateEmbeddedChild(childScopeName, stepId, Guid.NewGuid(), stage); embeddedSteps.Add(step); } } @@ -144,7 +144,7 @@ public async Task RunAsync(ActionRunStage stage) step.Stage = stage; step.Condition = stepData.Condition; ExecutionContext.Root.EmbeddedIntraActionState.TryGetValue(step.Action.Id, out var intraActionState); - step.ExecutionContext = ExecutionContext.CreateEmbeddedChild(childScopeName, stepData.ContextName, step.Action.Id, intraActionState: intraActionState, siblingScopeName: siblingScopeName); + step.ExecutionContext = ExecutionContext.CreateEmbeddedChild(childScopeName, stepData.ContextName, step.Action.Id, stage, intraActionState: intraActionState, siblingScopeName: siblingScopeName); step.ExecutionContext.ExpressionValues["inputs"] = inputsData; if (!String.IsNullOrEmpty(ExecutionContext.SiblingScopeName)) { @@ -242,6 +242,10 @@ private async Task RunStepsAsync(List embeddedSteps, ActionRunStage stage step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo(PipelineTemplateConstants.Failure, 0, 0)); step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo(PipelineTemplateConstants.Success, 0, 0)); + // Set action_status to the success of the current composite action + var actionResult = ExecutionContext.Result?.ToActionResult() ?? ActionResult.Success; + step.ExecutionContext.SetGitHubContext("action_status", actionResult.ToString()); + // Initialize env context Trace.Info("Initialize Env context for embedded step"); #if OS_WINDOWS @@ -296,108 +300,100 @@ private async Task RunStepsAsync(List embeddedSteps, ActionRunStage stage CancellationTokenRegistration? jobCancelRegister = null; try { - // For main steps just run the action - if (stage == ActionRunStage.Main) - { - await RunStepAsync(step); - } - // We need to evaluate conditions for pre/post steps - else + // Register job cancellation call back only if job cancellation token not been fire before each step run + if (!ExecutionContext.Root.CancellationToken.IsCancellationRequested) { - // Register job cancellation call back only if job cancellation token not been fire before each step run - if (!ExecutionContext.Root.CancellationToken.IsCancellationRequested) + // Test the condition again. The job was canceled after the condition was originally evaluated. + jobCancelRegister = ExecutionContext.Root.CancellationToken.Register(() => { - // Test the condition again. The job was canceled after the condition was originally evaluated. - jobCancelRegister = ExecutionContext.Root.CancellationToken.Register(() => + // Mark job as cancelled + ExecutionContext.Root.Result = TaskResult.Canceled; + ExecutionContext.Root.JobContext.Status = ExecutionContext.Root.Result?.ToActionResult(); + + step.ExecutionContext.Debug($"Re-evaluate condition on job cancellation for step: '{step.DisplayName}'."); + var conditionReTestTraceWriter = new ConditionTraceWriter(Trace, null); // host tracing only + var conditionReTestResult = false; + if (HostContext.RunnerShutdownToken.IsCancellationRequested) { - // Mark job as cancelled - ExecutionContext.Root.Result = TaskResult.Canceled; - ExecutionContext.Root.JobContext.Status = ExecutionContext.Root.Result?.ToActionResult(); - - step.ExecutionContext.Debug($"Re-evaluate condition on job cancellation for step: '{step.DisplayName}'."); - var conditionReTestTraceWriter = new ConditionTraceWriter(Trace, null); // host tracing only - var conditionReTestResult = false; - if (HostContext.RunnerShutdownToken.IsCancellationRequested) - { - step.ExecutionContext.Debug($"Skip Re-evaluate condition on runner shutdown."); - } - else + step.ExecutionContext.Debug($"Skip Re-evaluate condition on runner shutdown."); + } + else + { + try { - try - { - var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator(conditionReTestTraceWriter); - var condition = new BasicExpressionToken(null, null, null, step.Condition); - conditionReTestResult = templateEvaluator.EvaluateStepIf(condition, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, step.ExecutionContext.ToExpressionState()); - } - catch (Exception ex) - { - // Cancel the step since we get exception while re-evaluate step condition - Trace.Info("Caught exception from expression when re-test condition on job cancellation."); - step.ExecutionContext.Error(ex); - } + var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator(conditionReTestTraceWriter); + var condition = new BasicExpressionToken(null, null, null, step.Condition); + conditionReTestResult = templateEvaluator.EvaluateStepIf(condition, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, step.ExecutionContext.ToExpressionState()); } - - if (!conditionReTestResult) + catch (Exception ex) { - // Cancel the step - Trace.Info("Cancel current running step."); - step.ExecutionContext.CancelToken(); + // Cancel the step since we get exception while re-evaluate step condition + Trace.Info("Caught exception from expression when re-test condition on job cancellation."); + step.ExecutionContext.Error(ex); } - }); - } - else - { - if (ExecutionContext.Root.Result != TaskResult.Canceled) - { - // Mark job as cancelled - ExecutionContext.Root.Result = TaskResult.Canceled; - ExecutionContext.Root.JobContext.Status = ExecutionContext.Root.Result?.ToActionResult(); - } - } - // Evaluate condition - step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'"); - var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext); - var conditionResult = false; - var conditionEvaluateError = default(Exception); - if (HostContext.RunnerShutdownToken.IsCancellationRequested) - { - step.ExecutionContext.Debug($"Skip evaluate condition on runner shutdown."); - } - else - { - try - { - var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator(conditionTraceWriter); - var condition = new BasicExpressionToken(null, null, null, step.Condition); - conditionResult = templateEvaluator.EvaluateStepIf(condition, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, step.ExecutionContext.ToExpressionState()); } - catch (Exception ex) + + if (!conditionReTestResult) { - Trace.Info("Caught exception from expression."); - Trace.Error(ex); - conditionEvaluateError = ex; + // Cancel the step + Trace.Info("Cancel current running step."); + step.ExecutionContext.CancelToken(); } - } - if (!conditionResult && conditionEvaluateError == null) + }); + } + else + { + if (ExecutionContext.Root.Result != TaskResult.Canceled) { - // Condition is false - Trace.Info("Skipping step due to condition evaluation."); - step.ExecutionContext.Result = TaskResult.Skipped; - continue; + // Mark job as cancelled + ExecutionContext.Root.Result = TaskResult.Canceled; + ExecutionContext.Root.JobContext.Status = ExecutionContext.Root.Result?.ToActionResult(); } - else if (conditionEvaluateError != null) + } + // Evaluate condition + step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'"); + var conditionTraceWriter = new ConditionTraceWriter(Trace, step.ExecutionContext); + var conditionResult = false; + var conditionEvaluateError = default(Exception); + if (HostContext.RunnerShutdownToken.IsCancellationRequested) + { + step.ExecutionContext.Debug($"Skip evaluate condition on runner shutdown."); + } + else + { + try { - // Condition error - step.ExecutionContext.Error(conditionEvaluateError); - step.ExecutionContext.Result = TaskResult.Failed; - ExecutionContext.Result = TaskResult.Failed; - break; + var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator(conditionTraceWriter); + var condition = new BasicExpressionToken(null, null, null, step.Condition); + conditionResult = templateEvaluator.EvaluateStepIf(condition, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, step.ExecutionContext.ToExpressionState()); } - else + catch (Exception ex) { - await RunStepAsync(step); + Trace.Info("Caught exception from expression."); + Trace.Error(ex); + conditionEvaluateError = ex; } } + if (!conditionResult && conditionEvaluateError == null) + { + // Condition is false + Trace.Info("Skipping step due to condition evaluation."); + step.ExecutionContext.Result = TaskResult.Skipped; + continue; + } + else if (conditionEvaluateError != null) + { + // Condition error + step.ExecutionContext.Error(conditionEvaluateError); + step.ExecutionContext.Result = TaskResult.Failed; + ExecutionContext.Result = TaskResult.Failed; + break; + } + else + { + await RunStepAsync(step); + } + } finally { @@ -413,12 +409,6 @@ private async Task RunStepsAsync(List embeddedSteps, ActionRunStage stage { Trace.Info($"Update job result with current composite step result '{step.ExecutionContext.Result}'."); ExecutionContext.Result = TaskResultUtil.MergeTaskResults(ExecutionContext.Result, step.ExecutionContext.Result.Value); - - // We should run cleanup even if one of the cleanup step fails - if (stage != ActionRunStage.Post) - { - break; - } } } } diff --git a/src/Runner.Worker/JobExtension.cs b/src/Runner.Worker/JobExtension.cs index 613f408bdde..31487d6baac 100644 --- a/src/Runner.Worker/JobExtension.cs +++ b/src/Runner.Worker/JobExtension.cs @@ -55,7 +55,7 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel ArgUtil.NotNull(message, nameof(message)); // Create a new timeline record for 'Set up job' - IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Set up job", $"{nameof(JobExtension)}_Init", null, null); + IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Set up job", $"{nameof(JobExtension)}_Init", null, null, ActionRunStage.Pre); List preJobSteps = new List(); List jobSteps = new List(); @@ -306,13 +306,13 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel JobExtensionRunner extensionStep = step as JobExtensionRunner; ArgUtil.NotNull(extensionStep, extensionStep.DisplayName); Guid stepId = Guid.NewGuid(); - extensionStep.ExecutionContext = jobContext.CreateChild(stepId, extensionStep.DisplayName, null, null, stepId.ToString("N")); + extensionStep.ExecutionContext = jobContext.CreateChild(stepId, extensionStep.DisplayName, null, null, stepId.ToString("N"), ActionRunStage.Pre); } else if (step is IActionRunner actionStep) { ArgUtil.NotNull(actionStep, step.DisplayName); Guid stepId = Guid.NewGuid(); - actionStep.ExecutionContext = jobContext.CreateChild(stepId, actionStep.DisplayName, stepId.ToString("N"), null, null, intraActionStates[actionStep.Action.Id]); + actionStep.ExecutionContext = jobContext.CreateChild(stepId, actionStep.DisplayName, stepId.ToString("N"), null, null, ActionRunStage.Pre, intraActionStates[actionStep.Action.Id]); } } @@ -323,7 +323,7 @@ public async Task> InitializeJob(IExecutionContext jobContext, Pipel { ArgUtil.NotNull(actionStep, step.DisplayName); intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState); - actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, intraActionState); + actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState); } } @@ -394,7 +394,7 @@ public void FinalizeJob(IExecutionContext jobContext, Pipelines.AgentJobRequestM ArgUtil.NotNull(jobContext, nameof(jobContext)); // create a new timeline record node for 'Finalize job' - IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Complete job", $"{nameof(JobExtension)}_Final", null, null); + IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Complete job", $"{nameof(JobExtension)}_Final", null, null, ActionRunStage.Post); using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); })) { try diff --git a/src/Runner.Worker/action_yaml.json b/src/Runner.Worker/action_yaml.json index bcfc98f1589..3d766b851b0 100644 --- a/src/Runner.Worker/action_yaml.json +++ b/src/Runner.Worker/action_yaml.json @@ -123,6 +123,7 @@ "properties": { "name": "string-steps-context", "id": "non-empty-string", + "if": "step-if", "run": { "type": "string-steps-context", "required": true @@ -141,6 +142,7 @@ "properties": { "name": "string-steps-context", "id": "non-empty-string", + "if": "step-if", "uses": { "type": "non-empty-string", "required": true @@ -216,6 +218,24 @@ "loose-value-type": "string" } }, + "step-if": { + "context": [ + "github", + "inputs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "string": {} + }, "step-with": { "context": [ "github", diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 153e4e89b58..2cdde1a24ec 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -638,6 +638,7 @@ private static String ConvertToIfCondition( new NamedValueInfo(PipelineTemplateConstants.Matrix), new NamedValueInfo(PipelineTemplateConstants.Steps), new NamedValueInfo(PipelineTemplateConstants.GitHub), + new NamedValueInfo(PipelineTemplateConstants.Inputs), new NamedValueInfo(PipelineTemplateConstants.Job), new NamedValueInfo(PipelineTemplateConstants.Runner), new NamedValueInfo(PipelineTemplateConstants.Env), diff --git a/src/Test/L0/Worker/ActionManifestManagerL0.cs b/src/Test/L0/Worker/ActionManifestManagerL0.cs index 264c4199516..cd03b8bbd8d 100644 --- a/src/Test/L0/Worker/ActionManifestManagerL0.cs +++ b/src/Test/L0/Worker/ActionManifestManagerL0.cs @@ -626,6 +626,32 @@ public void Load_PluginAction() { Teardown(); } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Load_ConditionalCompositeAction() + { + try + { + //Arrange + Setup(); + + var actionManifest = new ActionManifestManager(); + actionManifest.Initialize(_hc); + + //Act + var result = actionManifest.Load(_ec.Object, Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml")); + + //Assert + Assert.Equal("Conditional Composite", result.Name); + Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType); + } + finally + { + Teardown(); + } } [Fact] diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index 4c20244a26d..170a1ef844c 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -193,9 +193,9 @@ public void RegisterPostJobAction_ShareState() // Act. jobContext.InitializeJob(jobRequest, CancellationToken.None); - var action1 = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null); + var action1 = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0); action1.IntraActionState["state"] = "1"; - var action2 = jobContext.CreateChild(Guid.NewGuid(), "action_2", "action_2", null, null); + var action2 = jobContext.CreateChild(Guid.NewGuid(), "action_2", "action_2", null, null, 0); action2.IntraActionState["state"] = "2"; @@ -291,8 +291,8 @@ public void RegisterPostJobAction_NotRegisterPostTwice() // Act. jobContext.InitializeJob(jobRequest, CancellationToken.None); - var action1 = jobContext.CreateChild(Guid.NewGuid(), "action_1_pre", "action_1_pre", null, null); - var action2 = jobContext.CreateChild(Guid.NewGuid(), "action_1_main", "action_1_main", null, null); + var action1 = jobContext.CreateChild(Guid.NewGuid(), "action_1_pre", "action_1_pre", null, null, 0); + var action2 = jobContext.CreateChild(Guid.NewGuid(), "action_1_main", "action_1_main", null, null, 0); var actionId = Guid.NewGuid(); var postRunner1 = hc.CreateService(); diff --git a/src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs b/src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs index 4ffcdc9dc42..2ca10bffdcc 100644 --- a/src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs +++ b/src/Test/L0/Worker/Expressions/ConditionFunctionsL0.cs @@ -105,6 +105,36 @@ public void FailureFunction() } } + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData(ActionResult.Failure, ActionResult.Failure, true)] + [InlineData(ActionResult.Failure, ActionResult.Success, false)] + [InlineData(ActionResult.Success, ActionResult.Failure, true)] + [InlineData(ActionResult.Success, ActionResult.Success, false)] + [InlineData(ActionResult.Success, null, false)] + public void FailureFunctionComposite(ActionResult jobStatus, ActionResult? actionStatus, bool expected) + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange + + var executionContext = InitializeExecutionContext(hc); + executionContext.Setup(x => x.GetGitHubContext("action_status")).Returns(actionStatus.ToString()); + executionContext.Setup( x=> x.IsEmbedded).Returns(true); + executionContext.Setup( x=> x.Stage).Returns(ActionRunStage.Main); + + _jobContext.Status = jobStatus; + + // Act. + bool actual = Evaluate("failure()"); + + // Assert. + Assert.Equal(expected, actual); + + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] @@ -134,12 +164,43 @@ public void SuccessFunction() } } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData(ActionResult.Failure, ActionResult.Failure, false)] + [InlineData(ActionResult.Failure, ActionResult.Success, true)] + [InlineData(ActionResult.Success, ActionResult.Failure, false)] + [InlineData(ActionResult.Success, ActionResult.Success, true)] + [InlineData(ActionResult.Success, null, true)] + public void SuccessFunctionComposite(ActionResult jobStatus, ActionResult? actionStatus, bool expected) + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange + + var executionContext = InitializeExecutionContext(hc); + executionContext.Setup(x => x.GetGitHubContext("action_status")).Returns(actionStatus.ToString()); + executionContext.Setup( x=> x.IsEmbedded).Returns(true); + executionContext.Setup( x=> x.Stage).Returns(ActionRunStage.Main); + + _jobContext.Status = jobStatus; + + // Act. + bool actual = Evaluate("success()"); + + // Assert. + Assert.Equal(expected, actual); + + } + } + private TestHostContext CreateTestContext([CallerMemberName] String testName = "") { return new TestHostContext(this, testName); } - private void InitializeExecutionContext(TestHostContext hc) + private Mock InitializeExecutionContext(TestHostContext hc) { _jobContext = new JobContext(); @@ -149,6 +210,8 @@ private void InitializeExecutionContext(TestHostContext hc) _templateContext = new TemplateContext(); _templateContext.State[nameof(IExecutionContext)] = executionContext.Object; + + return executionContext; } private bool Evaluate(string expression) diff --git a/src/Test/TestData/conditional_composite_action.yml b/src/Test/TestData/conditional_composite_action.yml new file mode 100644 index 00000000000..a567c959329 --- /dev/null +++ b/src/Test/TestData/conditional_composite_action.yml @@ -0,0 +1,49 @@ + +name: 'Conditional Composite' +description: 'Test composite run step conditionals' +inputs: + exit-code: + description: 'Action fails if set to non-zero' + default: '0' +outputs: + default: + description: "Did step run with default?" + value: ${{ steps.default-conditional.outputs.default }} + success: + description: "Did step run with success?" + value: ${{ steps.success-conditional.outputs.success }} + failure: + description: "Did step run with failure?" + value: ${{ steps.failure-conditional.outputs.failure }} + always: + description: "Did step run with always?" + value: ${{ steps.always-conditional.outputs.always }} + +runs: + using: "composite" + steps: + - run: exit ${{ inputs.exit-code }} + shell: bash + + - run: echo "::set-output name=default::true" + id: default-conditional + shell: bash + + - run: echo "::set-output name=success::true" + id: success-conditional + shell: bash + if: success() + + - run: echo "::set-output name=failure::true" + id: failure-conditional + shell: bash + if: failure() + + - run: echo "::set-output name=always::true" + id: always-conditional + shell: bash + if: always() + + - run: echo "failed" + shell: bash + if: ${{ inputs.exit-code == 1 && failure() }} \ No newline at end of file