Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/Runner.Common/ActionCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,26 @@ private static string Unescape(string escaped)
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))
Expand Down
2 changes: 2 additions & 0 deletions src/Runner.Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ public static class Features
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
Expand Down Expand Up @@ -284,6 +285,7 @@ public static class Agent
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
Expand Down
95 changes: 94 additions & 1 deletion src/Runner.Worker/Handlers/CompositeActionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -226,6 +227,11 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
{
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));

foreach (IStep step in embeddedSteps)
{
Trace.Info($"Processing embedded step: DisplayName='{step.DisplayName}'");
Expand Down Expand Up @@ -297,6 +303,20 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
SetStepConclusion(step, TaskResult.Failed);
}

// Marker ID uses the step's fully qualified context name (ScopeName.ContextName),
// which encodes the full composite nesting chain at any depth.
var markerId = emitCompositeMarkers ? step.ExecutionContext.GetFullyQualifiedContextName() : null;
var stepStopwatch = default(Stopwatch);
var endMarkerEmitted = false;

// Emit start marker after full context setup so display name expressions resolve correctly
if (emitCompositeMarkers)
{
step.TryUpdateDisplayName(out _);
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
stepStopwatch = Stopwatch.StartNew();
}

// Register Callback
CancellationTokenRegistration? jobCancelRegister = null;
try
Expand Down Expand Up @@ -381,6 +401,14 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
// 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(markerId)};outcome=skipped;conclusion=skipped;duration_ms=0]");
endMarkerEmitted = true;
}

continue;
}
else if (conditionEvaluateError != null)
Expand All @@ -389,13 +417,31 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
step.ExecutionContext.Error(conditionEvaluateError);
SetStepConclusion(step, TaskResult.Failed);
ExecutionContext.Result = TaskResult.Failed;

if (emitCompositeMarkers)
{
stepStopwatch.Stop();
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=failure;conclusion=failure;duration_ms={stepStopwatch.ElapsedMilliseconds}]");
endMarkerEmitted = true;
}

break;
}
else
{
await RunStepAsync(step);
}

if (emitCompositeMarkers)
{
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(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
endMarkerEmitted = true;
}
}
}
finally
{
Expand All @@ -404,6 +450,14 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
jobCancelRegister?.Dispose();
jobCancelRegister = null;
}

if (emitCompositeMarkers && !endMarkerEmitted)
{
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(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
}
}
// Check failed or cancelled
if (step.ExecutionContext.Result == TaskResult.Failed || step.ExecutionContext.Result == TaskResult.Canceled)
Expand Down Expand Up @@ -470,5 +524,44 @@ private void SetStepConclusion(IStep step, TaskResult result)
step.ExecutionContext.Result = result;
step.ExecutionContext.UpdateGlobalStepsContext();
}

/// <summary>
/// Escapes marker property values so they cannot break the ##[command key=value] format.
/// Delegates to ActionCommand.EscapeValue which escapes `;`, `]`, `\r`, `\n`, and `%`.
/// </summary>
internal static string EscapeProperty(string value)
{
return ActionCommand.EscapeValue(value);
}

/// <summary>Maximum character length for display names in markers to prevent log bloat.</summary>
internal const int MaxDisplayNameLength = 1000;

/// <summary>
/// Normalizes a step display name for safe embedding in a marker property.
/// Trims leading whitespace, drops everything after the first newline, and
/// truncates to <see cref="MaxDisplayNameLength"/> characters.
/// </summary>
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;
}
}
}
8 changes: 8 additions & 0 deletions src/Runner.Worker/Handlers/OutputManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ public void OnDataReceived(object sender, ProcessDataReceivedEventArgs e)
}
}

// 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)
{
Expand Down
Loading
Loading