Compare commits

..

2 Commits

Author SHA1 Message Date
eric sciple
2a1588d3f8 fixes 2026-02-13 12:19:14 -08:00
eric sciple
d78d0246f1 Composite Action Step Markers
## Summary

Emit `##[start-action]` / `##[end-action]` markers in the log stream around each nested step inside a composite action. The UI parses these markers to render collapsible regions, giving users visibility into individual steps that were previously hidden in a single opaque log blob.

## Design

The runner writes `##[` markers directly to the log stream via `ExecutionContext.Output()`, bypassing the logging command pipeline. No new `::` logging command is registered. Users cannot emit these markers from scripts.

### Marker format

```
##[start-action display=<step-display-name>;id=<step-id>]

... step output ...

##[end-action id=<step-id>;outcome=<result>;conclusion=<result>;duration_ms=<ms>]
```

- **id** — the step's `ContextName` (from YAML `id:` or auto-generated with `__` prefix)
- **outcome** — raw result before `continue-on-error` is applied
- **conclusion** — final result after `continue-on-error`
- **duration_ms** — wall-clock milliseconds (0 for skipped steps)

Nested composites use dot-separated IDs (e.g. `outer.inner-step`) to keep each step globally unique.

### Feature flag

Gated behind `actions_runner_emit_composite_markers` (job message variable) with `ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS` env var fallback (for internal testing only).

### Injection prevention

`OutputManager.OnDataReceived` replaces `##[start-action` and `##[end-action` from user process stdout/stderr with `##[\start-action` and `##[\end-action`. This preventing users from injecting fake markers. The runner's own markers bypass `OutputManager` entirely since they're written via `ExecutionContext.Output()` directly.
2026-02-13 12:19:14 -08:00
7 changed files with 615 additions and 147 deletions

View File

@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
NODE20_VERSION="20.20.0"
NODE24_VERSION="24.13.1"
NODE24_VERSION="24.13.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -204,6 +204,26 @@ namespace GitHub.Runner.Common
return unescaped;
}
/// <summary>
/// Escapes special characters in a value using the standard action command escape mappings.
/// Iterates in reverse so that '%' is escaped first to avoid double-encoding.
/// </summary>
public static string EscapeValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
string escaped = value;
for (int i = _escapeMappings.Length - 1; i >= 0; i--)
{
escaped = escaped.Replace(_escapeMappings[i].Token, _escapeMappings[i].Replacement);
}
return escaped;
}
private static string UnescapeProperty(string escaped)
{
if (string.IsNullOrEmpty(escaped))

View File

@@ -174,6 +174,7 @@ namespace GitHub.Runner.Common
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
}
// Node version migration related constants
@@ -284,6 +285,7 @@ namespace GitHub.Runner.Common
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
}
public static class System

View File

@@ -226,183 +226,261 @@ namespace GitHub.Runner.Worker.Handlers
{
ArgUtil.NotNull(embeddedSteps, nameof(embeddedSteps));
bool emitCompositeMarkers =
(ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.EmitCompositeMarkers) ?? false)
|| StringUtil.ConvertToBoolean(
System.Environment.GetEnvironmentVariable(Constants.Variables.Agent.EmitCompositeMarkers));
// Read nesting prefix from execution context (set by parent composite, if any)
string idPrefix = "";
if (emitCompositeMarkers)
{
idPrefix = ExecutionContext.Global.Variables.Get("system.compositeMarkerIdPrefix") ?? "";
}
var containerSetupIndex = 0;
foreach (IStep step in embeddedSteps)
{
Trace.Info($"Processing embedded step: DisplayName='{step.DisplayName}'");
// Add Expression Functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(PipelineTemplateConstants.Success, 0, 0));
// Build step ID from ContextName for markers
var contextName = (step as IActionRunner)?.Action?.ContextName ?? $"__container_setup_{containerSetupIndex++}";
var stepId = string.IsNullOrEmpty(idPrefix) ? contextName : $"{idPrefix}.{contextName}";
var stepStopwatch = new System.Diagnostics.Stopwatch();
// 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().ToLowerInvariant());
// Emit start marker before condition evaluation
var endMarkerEmitted = false;
if (emitCompositeMarkers)
{
step.TryUpdateDisplayName(out _);
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(stepId)}]");
stepStopwatch.Start();
}
// Initialize env context
Trace.Info("Initialize Env context for embedded step");
try
{
// Add Expression Functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<HashFilesFunction>(PipelineTemplateConstants.HashFiles, 1, byte.MaxValue));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<CancelledFunction>(PipelineTemplateConstants.Cancelled, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<FailureFunction>(PipelineTemplateConstants.Failure, 0, 0));
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<SuccessFunction>(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().ToLowerInvariant());
// Initialize env context
Trace.Info("Initialize Env context for embedded step");
#if OS_WINDOWS
var envContext = new DictionaryContextData();
#else
var envContext = new CaseSensitiveDictionaryContextData();
var envContext = new CaseSensitiveDictionaryContextData();
#endif
step.ExecutionContext.ExpressionValues["env"] = envContext;
step.ExecutionContext.ExpressionValues["env"] = envContext;
// Merge global env
foreach (var pair in ExecutionContext.Global.EnvironmentVariables)
{
envContext[pair.Key] = new StringContextData(pair.Value ?? string.Empty);
}
// Merge global env
foreach (var pair in ExecutionContext.Global.EnvironmentVariables)
{
envContext[pair.Key] = new StringContextData(pair.Value ?? string.Empty);
}
// Merge composite-step env
if (ExecutionContext.ExpressionValues.TryGetValue("env", out var envContextData))
{
// Merge composite-step env
if (ExecutionContext.ExpressionValues.TryGetValue("env", out var envContextData))
{
#if OS_WINDOWS
var dict = envContextData as DictionaryContextData;
#else
var dict = envContextData as CaseSensitiveDictionaryContextData;
var dict = envContextData as CaseSensitiveDictionaryContextData;
#endif
foreach (var pair in dict)
{
// Skip global env, otherwise we merge an outdated global env
if (ExecutionContext.StepEnvironmentOverrides.Contains(pair.Key))
foreach (var pair in dict)
{
envContext[pair.Key] = pair.Value;
}
}
}
try
{
if (step is IActionRunner actionStep)
{
// Evaluate and merge embedded-step env
step.ExecutionContext.StepEnvironmentOverrides.AddRange(ExecutionContext.StepEnvironmentOverrides);
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
var actionEnvironment = templateEvaluator.EvaluateStepEnvironment(actionStep.Action.Environment, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, Common.Util.VarUtil.EnvironmentVariableKeyComparer);
foreach (var env in actionEnvironment)
{
envContext[env.Key] = new StringContextData(env.Value ?? string.Empty);
step.ExecutionContext.StepEnvironmentOverrides.Add(env.Key);
}
}
}
catch (Exception ex)
{
// Evaluation error
Trace.Info("Caught exception from expression for embedded step.env");
step.ExecutionContext.Error(ex);
SetStepConclusion(step, TaskResult.Failed);
}
// Register Callback
CancellationTokenRegistration? jobCancelRegister = null;
try
{
// 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 cancelled 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.SetGitHubContext("action_status", (ExecutionContext.Root.Result?.ToActionResult() ?? ActionResult.Cancelled).ToString().ToLowerInvariant());
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)
// Skip global env, otherwise we merge an outdated global env
if (ExecutionContext.StepEnvironmentOverrides.Contains(pair.Key))
{
step.ExecutionContext.Debug($"Skip Re-evaluate condition on runner shutdown.");
}
else
{
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);
}
envContext[pair.Key] = pair.Value;
}
}
}
if (!conditionReTestResult)
try
{
if (step is IActionRunner actionStep)
{
// Evaluate and merge embedded-step env
step.ExecutionContext.StepEnvironmentOverrides.AddRange(ExecutionContext.StepEnvironmentOverrides);
var templateEvaluator = step.ExecutionContext.ToPipelineTemplateEvaluator();
var actionEnvironment = templateEvaluator.EvaluateStepEnvironment(actionStep.Action.Environment, step.ExecutionContext.ExpressionValues, step.ExecutionContext.ExpressionFunctions, Common.Util.VarUtil.EnvironmentVariableKeyComparer);
foreach (var env in actionEnvironment)
{
// Cancel the step
Trace.Info("Cancel current running step.");
step.ExecutionContext.CancelToken();
envContext[env.Key] = new StringContextData(env.Value ?? string.Empty);
step.ExecutionContext.StepEnvironmentOverrides.Add(env.Key);
}
});
}
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)
catch (Exception ex)
{
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)
{
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.");
SetStepConclusion(step, TaskResult.Skipped);
continue;
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
// Evaluation error
Trace.Info("Caught exception from expression for embedded step.env");
step.ExecutionContext.Error(ex);
SetStepConclusion(step, TaskResult.Failed);
ExecutionContext.Result = TaskResult.Failed;
break;
}
else
{
await RunStepAsync(step);
}
}
// Register Callback
CancellationTokenRegistration? jobCancelRegister = null;
try
{
// 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 cancelled 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.SetGitHubContext("action_status", (ExecutionContext.Root.Result?.ToActionResult() ?? ActionResult.Cancelled).ToString().ToLowerInvariant());
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
{
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);
}
}
if (!conditionReTestResult)
{
// Cancel the step
Trace.Info("Cancel current running step.");
step.ExecutionContext.CancelToken();
}
});
}
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)
{
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.");
SetStepConclusion(step, TaskResult.Skipped);
if (emitCompositeMarkers)
{
stepStopwatch.Stop();
ExecutionContext.Output($"##[end-action id={EscapeProperty(stepId)};outcome=skipped;conclusion=skipped;duration_ms=0]");
endMarkerEmitted = true;
}
continue;
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
SetStepConclusion(step, TaskResult.Failed);
ExecutionContext.Result = TaskResult.Failed;
if (emitCompositeMarkers)
{
stepStopwatch.Stop();
ExecutionContext.Output($"##[end-action id={EscapeProperty(stepId)};outcome=failure;conclusion=failure;duration_ms={stepStopwatch.ElapsedMilliseconds}]");
endMarkerEmitted = true;
}
break;
}
else
{
// Set the prefix for nested composites BEFORE running the step
if (emitCompositeMarkers)
{
ExecutionContext.Global.Variables.Set("system.compositeMarkerIdPrefix", stepId);
}
await RunStepAsync(step);
if (emitCompositeMarkers)
{
// Restore prefix for sibling steps (in case a nested composite changed it)
ExecutionContext.Global.Variables.Set("system.compositeMarkerIdPrefix", idPrefix);
stepStopwatch.Stop();
// Outcome = raw result before continue-on-error (null when continue-on-error didn't fire)
// Result = final result after continue-on-error
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
ExecutionContext.Output($"##[end-action id={EscapeProperty(stepId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
endMarkerEmitted = true;
}
}
}
finally
{
if (jobCancelRegister != null)
{
jobCancelRegister?.Dispose();
jobCancelRegister = null;
}
}
} // end marker safety try
finally
{
if (jobCancelRegister != null)
if (emitCompositeMarkers && !endMarkerEmitted)
{
jobCancelRegister?.Dispose();
jobCancelRegister = null;
stepStopwatch.Stop();
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
ExecutionContext.Output($"##[end-action id={EscapeProperty(stepId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
}
}
// Check failed or cancelled
@@ -470,5 +548,34 @@ namespace GitHub.Runner.Worker.Handlers
step.ExecutionContext.Result = result;
step.ExecutionContext.UpdateGlobalStepsContext();
}
internal static string EscapeProperty(string value)
{
return ActionCommand.EscapeValue(value);
}
internal const int MaxDisplayNameLength = 1000;
internal static string SanitizeDisplayName(string displayName)
{
if (string.IsNullOrEmpty(displayName)) return displayName;
// Take first line only (FormatStepName in ActionRunner.cs already does this
// for most cases, but be defensive for any code path that skips it)
var result = displayName.TrimStart(' ', '\t', '\r', '\n');
var firstNewLine = result.IndexOfAny(new[] { '\r', '\n' });
if (firstNewLine >= 0)
{
result = result.Substring(0, firstNewLine);
}
// Truncate excessively long names
if (result.Length > MaxDisplayNameLength)
{
result = result.Substring(0, MaxDisplayNameLength);
}
return result;
}
}
}

View File

@@ -90,6 +90,14 @@ namespace GitHub.Runner.Worker.Handlers
}
}
// Strip runner-controlled markers from user output to prevent injection
if (!String.IsNullOrEmpty(line) &&
(line.Contains("##[start-action") || line.Contains("##[end-action")))
{
line = line.Replace("##[start-action", @"##[\start-action")
.Replace("##[end-action", @"##[\end-action");
}
// Problem matchers
if (_matchers.Length > 0)
{

View File

@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
using DTWebApi = GitHub.DistributedTask.WebApi;
namespace GitHub.Runner.Common.Tests.Worker.Handlers
{
public sealed class CompositeActionHandlerL0
{
// Test EscapeProperty helper logic via reflection or by testing the markers output
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EscapeProperty_EscapesSpecialCharacters()
{
// Test the escaping logic that would be applied
var input = "value;with%special\r\n]chars";
var escaped = EscapeProperty(input);
Assert.Equal("value%3Bwith%25special%0D%0A%5Dchars", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EscapeProperty_HandlesNullAndEmpty()
{
Assert.Null(EscapeProperty(null));
Assert.Equal("", EscapeProperty(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TruncatesLongNames()
{
var longName = new string('a', 1500);
var sanitized = SanitizeDisplayName(longName);
Assert.Equal(CompositeActionHandler.MaxDisplayNameLength, sanitized.Length);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TakesFirstLineOnly()
{
var multiline = "First line\nSecond line\nThird line";
var sanitized = SanitizeDisplayName(multiline);
Assert.Equal("First line", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TrimsLeadingWhitespace()
{
var withLeading = " \n \t Actual name\nSecond line";
var sanitized = SanitizeDisplayName(withLeading);
Assert.Equal("Actual name", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_HandlesCarriageReturn()
{
var withCR = "First line\r\nSecond line";
var sanitized = SanitizeDisplayName(withCR);
Assert.Equal("First line", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_HandlesNullAndEmpty()
{
Assert.Null(SanitizeDisplayName(null));
Assert.Equal("", SanitizeDisplayName(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmitMarkers_DisplayNameEscaping()
{
// Verify that special characters in display names get escaped properly
var displayName = "Step with semicolons; and more; here";
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
Assert.Equal("Step with semicolons%3B and more%3B here", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmitMarkers_DisplayNameWithBrackets()
{
var displayName = "Step with [brackets] inside";
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
Assert.Equal("Step with [brackets%5D inside", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_StartAction()
{
// Simulate what OutputManager does to strip markers
var userLine = "##[start-action display=Fake;id=fake]";
var stripped = StripMarkers(userLine);
Assert.Equal(@"##[\start-action display=Fake;id=fake]", stripped);
Assert.DoesNotContain("##[start-action", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_EndAction()
{
var userLine = "##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]";
var stripped = StripMarkers(userLine);
Assert.Equal(@"##[\end-action id=fake;outcome=success;conclusion=success;duration_ms=100]", stripped);
Assert.DoesNotContain("##[end-action", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_PreservesOtherCommands()
{
var userLine = "##[group]My Group";
var stripped = StripMarkers(userLine);
Assert.Equal("##[group]My Group", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_HandlesEmbeddedMarkers()
{
var userLine = "Some text ##[start-action display=fake;id=fake] more text";
var stripped = StripMarkers(userLine);
Assert.Equal(@"Some text ##[\start-action display=fake;id=fake] more text", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Success()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Succeeded;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Success, actionResult);
Assert.Equal("success", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Failure()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Failed;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Failure, actionResult);
Assert.Equal("failure", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Cancelled()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Canceled;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Cancelled, actionResult);
Assert.Equal("cancelled", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Skipped()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Skipped;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Skipped, actionResult);
Assert.Equal("skipped", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_StartAction()
{
var display = "My Step";
var id = "my-step";
var marker = $"##[start-action display={EscapeProperty(display)};id={EscapeProperty(id)}]";
Assert.Equal("##[start-action display=My Step;id=my-step]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_EndAction()
{
var id = "my-step";
var outcome = "success";
var conclusion = "success";
var durationMs = 1234;
var marker = $"##[end-action id={EscapeProperty(id)};outcome={outcome};conclusion={conclusion};duration_ms={durationMs}]";
Assert.Equal("##[end-action id=my-step;outcome=success;conclusion=success;duration_ms=1234]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_NestedId()
{
var prefix = "outer-composite";
var contextName = "inner-step";
var stepId = $"{prefix}.{contextName}";
Assert.Equal("outer-composite.inner-step", stepId);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_SkippedStep()
{
var id = "skipped-step";
var marker = $"##[end-action id={EscapeProperty(id)};outcome=skipped;conclusion=skipped;duration_ms=0]";
Assert.Equal("##[end-action id=skipped-step;outcome=skipped;conclusion=skipped;duration_ms=0]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_ContinueOnError()
{
// When continue-on-error is true and step fails:
// outcome = failure (raw result)
// conclusion = success (after continue-on-error applied)
var id = "failing-step";
var marker = $"##[end-action id={EscapeProperty(id)};outcome=failure;conclusion=success;duration_ms=500]";
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
}
// Helper methods that call the real production code
private static string EscapeProperty(string value) =>
CompositeActionHandler.EscapeProperty(value);
private static string SanitizeDisplayName(string displayName) =>
CompositeActionHandler.SanitizeDisplayName(displayName);
private static string StripMarkers(string line)
{
if (!string.IsNullOrEmpty(line) &&
(line.Contains("##[start-action") || line.Contains("##[end-action")))
{
line = line.Replace("##[start-action", @"##[\start-action")
.Replace("##[end-action", @"##[\end-action");
}
return line;
}
}
}

View File

@@ -1006,6 +1006,66 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_StartAction()
{
using (Setup())
using (_outputManager)
{
Process("##[start-action display=Fake;id=fake]");
Assert.Single(_messages);
Assert.Contains(@"##[\start-action", _messages[0]);
Assert.DoesNotContain("##[start-action", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_EndAction()
{
using (Setup())
using (_outputManager)
{
Process("##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]");
Assert.Single(_messages);
Assert.Contains(@"##[\end-action", _messages[0]);
Assert.DoesNotContain("##[end-action", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_PreservesOtherCommands()
{
using (Setup())
using (_outputManager)
{
Process("##[group]My Group");
// Should not be stripped (not a composite marker)
Assert.Single(_messages);
Assert.Equal("##[group]My Group", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_EmbeddedInLine()
{
using (Setup())
using (_outputManager)
{
Process("Some text ##[start-action display=fake;id=fake] more text");
Assert.Single(_messages);
Assert.Contains(@"##[\start-action", _messages[0]);
Assert.DoesNotContain("##[start-action", _messages[0]);
}
}
private TestHostContext Setup(
[CallerMemberName] string name = "",
IssueMatchersConfig matchers = null,