Skip to content

Commit ecb5f29

Browse files
authored
Composite Action Step Markers (#4243)
1 parent a2b2209 commit ecb5f29

File tree

6 files changed

+455
-1
lines changed

6 files changed

+455
-1
lines changed

src/Runner.Common/ActionCommand.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,26 @@ private static string Unescape(string escaped)
204204
return unescaped;
205205
}
206206

207+
/// <summary>
208+
/// Escapes special characters in a value using the standard action command escape mappings.
209+
/// Iterates in reverse so that '%' is escaped first to avoid double-encoding.
210+
/// </summary>
211+
public static string EscapeValue(string value)
212+
{
213+
if (string.IsNullOrEmpty(value))
214+
{
215+
return value;
216+
}
217+
218+
string escaped = value;
219+
for (int i = _escapeMappings.Length - 1; i >= 0; i--)
220+
{
221+
escaped = escaped.Replace(_escapeMappings[i].Token, _escapeMappings[i].Replacement);
222+
}
223+
224+
return escaped;
225+
}
226+
207227
private static string UnescapeProperty(string escaped)
208228
{
209229
if (string.IsNullOrEmpty(escaped))

src/Runner.Common/Constants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ public static class Features
174174
public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser";
175175
public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions";
176176
public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations";
177+
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
177178
}
178179

179180
// Node version migration related constants
@@ -288,6 +289,7 @@ public static class Agent
288289
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
289290
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
290291
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
292+
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
291293
}
292294

293295
public static class System

src/Runner.Worker/Handlers/CompositeActionHandler.cs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.Linq;
45
using System.Threading;
56
using System.Threading.Tasks;
@@ -226,6 +227,11 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
226227
{
227228
ArgUtil.NotNull(embeddedSteps, nameof(embeddedSteps));
228229

230+
bool emitCompositeMarkers =
231+
(ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.EmitCompositeMarkers) ?? false)
232+
|| StringUtil.ConvertToBoolean(
233+
System.Environment.GetEnvironmentVariable(Constants.Variables.Agent.EmitCompositeMarkers));
234+
229235
foreach (IStep step in embeddedSteps)
230236
{
231237
Trace.Info($"Processing embedded step: DisplayName='{step.DisplayName}'");
@@ -297,6 +303,20 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
297303
SetStepConclusion(step, TaskResult.Failed);
298304
}
299305

306+
// Marker ID uses the step's fully qualified context name (ScopeName.ContextName),
307+
// which encodes the full composite nesting chain at any depth.
308+
var markerId = emitCompositeMarkers ? step.ExecutionContext.GetFullyQualifiedContextName() : null;
309+
var stepStopwatch = default(Stopwatch);
310+
var endMarkerEmitted = false;
311+
312+
// Emit start marker after full context setup so display name expressions resolve correctly
313+
if (emitCompositeMarkers)
314+
{
315+
step.TryUpdateDisplayName(out _);
316+
ExecutionContext.Output($"##[start-action display={EscapeProperty(SanitizeDisplayName(step.DisplayName))};id={EscapeProperty(markerId)}]");
317+
stepStopwatch = Stopwatch.StartNew();
318+
}
319+
300320
// Register Callback
301321
CancellationTokenRegistration? jobCancelRegister = null;
302322
try
@@ -381,6 +401,14 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
381401
// Condition is false
382402
Trace.Info("Skipping step due to condition evaluation.");
383403
SetStepConclusion(step, TaskResult.Skipped);
404+
405+
if (emitCompositeMarkers)
406+
{
407+
stepStopwatch.Stop();
408+
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=skipped;conclusion=skipped;duration_ms=0]");
409+
endMarkerEmitted = true;
410+
}
411+
384412
continue;
385413
}
386414
else if (conditionEvaluateError != null)
@@ -389,13 +417,31 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
389417
step.ExecutionContext.Error(conditionEvaluateError);
390418
SetStepConclusion(step, TaskResult.Failed);
391419
ExecutionContext.Result = TaskResult.Failed;
420+
421+
if (emitCompositeMarkers)
422+
{
423+
stepStopwatch.Stop();
424+
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome=failure;conclusion=failure;duration_ms={stepStopwatch.ElapsedMilliseconds}]");
425+
endMarkerEmitted = true;
426+
}
427+
392428
break;
393429
}
394430
else
395431
{
396432
await RunStepAsync(step);
397-
}
398433

434+
if (emitCompositeMarkers)
435+
{
436+
stepStopwatch.Stop();
437+
// Outcome = raw result before continue-on-error (null when continue-on-error didn't fire)
438+
// Result = final result after continue-on-error
439+
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
440+
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Succeeded).ToActionResult().ToString().ToLowerInvariant();
441+
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
442+
endMarkerEmitted = true;
443+
}
444+
}
399445
}
400446
finally
401447
{
@@ -404,6 +450,14 @@ private async Task RunStepsAsync(List<IStep> embeddedSteps, ActionRunStage stage
404450
jobCancelRegister?.Dispose();
405451
jobCancelRegister = null;
406452
}
453+
454+
if (emitCompositeMarkers && !endMarkerEmitted)
455+
{
456+
stepStopwatch.Stop();
457+
var outcome = (step.ExecutionContext.Outcome ?? step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
458+
var conclusion = (step.ExecutionContext.Result ?? TaskResult.Failed).ToActionResult().ToString().ToLowerInvariant();
459+
ExecutionContext.Output($"##[end-action id={EscapeProperty(markerId)};outcome={outcome};conclusion={conclusion};duration_ms={stepStopwatch.ElapsedMilliseconds}]");
460+
}
407461
}
408462
// Check failed or cancelled
409463
if (step.ExecutionContext.Result == TaskResult.Failed || step.ExecutionContext.Result == TaskResult.Canceled)
@@ -470,5 +524,44 @@ private void SetStepConclusion(IStep step, TaskResult result)
470524
step.ExecutionContext.Result = result;
471525
step.ExecutionContext.UpdateGlobalStepsContext();
472526
}
527+
528+
/// <summary>
529+
/// Escapes marker property values so they cannot break the ##[command key=value] format.
530+
/// Delegates to ActionCommand.EscapeValue which escapes `;`, `]`, `\r`, `\n`, and `%`.
531+
/// </summary>
532+
internal static string EscapeProperty(string value)
533+
{
534+
return ActionCommand.EscapeValue(value);
535+
}
536+
537+
/// <summary>Maximum character length for display names in markers to prevent log bloat.</summary>
538+
internal const int MaxDisplayNameLength = 1000;
539+
540+
/// <summary>
541+
/// Normalizes a step display name for safe embedding in a marker property.
542+
/// Trims leading whitespace, drops everything after the first newline, and
543+
/// truncates to <see cref="MaxDisplayNameLength"/> characters.
544+
/// </summary>
545+
internal static string SanitizeDisplayName(string displayName)
546+
{
547+
if (string.IsNullOrEmpty(displayName)) return displayName;
548+
549+
// Take first line only (FormatStepName in ActionRunner.cs already does this
550+
// for most cases, but be defensive for any code path that skips it)
551+
var result = displayName.TrimStart(' ', '\t', '\r', '\n');
552+
var firstNewLine = result.IndexOfAny(new[] { '\r', '\n' });
553+
if (firstNewLine >= 0)
554+
{
555+
result = result.Substring(0, firstNewLine);
556+
}
557+
558+
// Truncate excessively long names
559+
if (result.Length > MaxDisplayNameLength)
560+
{
561+
result = result.Substring(0, MaxDisplayNameLength);
562+
}
563+
564+
return result;
565+
}
473566
}
474567
}

src/Runner.Worker/Handlers/OutputManager.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ public void OnDataReceived(object sender, ProcessDataReceivedEventArgs e)
9090
}
9191
}
9292

93+
// Strip runner-controlled markers from user output to prevent injection
94+
if (!String.IsNullOrEmpty(line) &&
95+
(line.Contains("##[start-action") || line.Contains("##[end-action")))
96+
{
97+
line = line.Replace("##[start-action", @"##[\start-action")
98+
.Replace("##[end-action", @"##[\end-action");
99+
}
100+
93101
// Problem matchers
94102
if (_matchers.Length > 0)
95103
{

0 commit comments

Comments
 (0)