11using System ;
22using System . Collections . Generic ;
3+ using System . Diagnostics ;
34using System . Linq ;
45using System . Threading ;
56using 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}
0 commit comments