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
150 changes: 146 additions & 4 deletions src/Runner.Worker/ActionManifestManagerWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ public Dictionary<string, string> EvaluateContainerEnvironment(
"EvaluateContainerEnvironment",
() => _legacyManager.EvaluateContainerEnvironment(executionContext, token, extraExpressionValues),
() => _newManager.EvaluateContainerEnvironment(executionContext, ConvertToNewToken(token) as GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.MappingToken, ConvertToNewExpressionValues(extraExpressionValues)),
(legacyResult, newResult) => {
(legacyResult, newResult) =>
{
var trace = HostContext.GetTrace(nameof(ActionManifestManagerWrapper));
return CompareDictionaries(trace, legacyResult, newResult, "ContainerEnvironment");
});
Expand Down Expand Up @@ -165,9 +166,150 @@ private ActionExecutionData ConvertToLegacyExecution(ActionExecutionData executi
return null;
}

// Serialize new steps and deserialize to old steps
var json = StringUtil.ConvertToJson(newSteps, Newtonsoft.Json.Formatting.None);
return StringUtil.ConvertFromJson<List<GitHub.DistributedTask.Pipelines.ActionStep>>(json);
var result = new List<GitHub.DistributedTask.Pipelines.ActionStep>();
foreach (var step in newSteps)
{
var actionStep = new GitHub.DistributedTask.Pipelines.ActionStep
{
ContextName = step.Id,
};

if (step is GitHub.Actions.WorkflowParser.RunStep runStep)
{
actionStep.Condition = ExtractConditionString(runStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(runStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(runStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(runStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(runStep.Env);
actionStep.Reference = new GitHub.DistributedTask.Pipelines.ScriptReference();
actionStep.Inputs = BuildRunStepInputs(runStep);
}
else if (step is GitHub.Actions.WorkflowParser.ActionStep usesStep)
{
actionStep.Condition = ExtractConditionString(usesStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(usesStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(usesStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(usesStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(usesStep.Env);
actionStep.Reference = ParseActionReference(usesStep.Uses?.Value);
actionStep.Inputs = ConvertToLegacyToken<MappingToken>(usesStep.With);
}

result.Add(actionStep);
}
return result;
}

private string ExtractConditionString(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken ifToken)
{
if (ifToken == null)
{
return null;
}

// The Expression property is internal, so we use ToString() which formats as "${{ expr }}"
// Then strip the delimiters to get just the expression
var str = ifToken.ToString();
if (str.StartsWith("${{") && str.EndsWith("}}"))
{
return str.Substring(3, str.Length - 5).Trim();
}
return str;
}

private MappingToken BuildRunStepInputs(GitHub.Actions.WorkflowParser.RunStep runStep)
{
var inputs = new MappingToken(null, null, null);

// script (from run)
if (runStep.Run != null)
{
inputs.Add(
new StringToken(null, null, null, "script"),
ConvertToLegacyToken<TemplateToken>(runStep.Run));
}

// shell
if (runStep.Shell != null)
{
inputs.Add(
new StringToken(null, null, null, "shell"),
ConvertToLegacyToken<TemplateToken>(runStep.Shell));
}

// working-directory
if (runStep.WorkingDirectory != null)
{
inputs.Add(
new StringToken(null, null, null, "workingDirectory"),
ConvertToLegacyToken<TemplateToken>(runStep.WorkingDirectory));
}

return inputs.Count > 0 ? inputs : null;
}

private GitHub.DistributedTask.Pipelines.ActionStepDefinitionReference ParseActionReference(string uses)
{
if (string.IsNullOrEmpty(uses))
{
return null;
}

// Docker reference: docker://image:tag
if (uses.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
{
return new GitHub.DistributedTask.Pipelines.ContainerRegistryReference
{
Image = uses.Substring("docker://".Length)
};
}

// Local path reference: ./path/to/action
if (uses.StartsWith("./") || uses.StartsWith(".\\"))
{
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "self",
Path = uses
};
}

// Repository reference: owner/repo@ref or owner/repo/path@ref
var atIndex = uses.LastIndexOf('@');
string refPart = null;
string repoPart = uses;

if (atIndex > 0)
{
refPart = uses.Substring(atIndex + 1);
repoPart = uses.Substring(0, atIndex);
}

// Split by / to get owner/repo and optional path
var parts = repoPart.Split('/');
string name;
string path = null;

if (parts.Length >= 2)
{
name = $"{parts[0]}/{parts[1]}";
if (parts.Length > 2)
{
path = string.Join("/", parts, 2, parts.Length - 2);
}
}
else
{
name = repoPart;
}

return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "GitHub",
Name = name,
Ref = refPart,
Path = path
};
}

private T ConvertToLegacyToken<T>(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken
Expand Down
3 changes: 3 additions & 0 deletions src/Runner.Worker/InternalsVisibleTo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Test")]
67 changes: 65 additions & 2 deletions src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.Expressions2;
Expand Down Expand Up @@ -222,6 +222,9 @@ private TLegacy EvaluateAndCompare<TLegacy, TNew>(
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Capture cancellation state before evaluation
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helps eliminate noise due to a cancellation race condition — i.e. cancellation fires after legacy evaluation, before new evaluation

var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;

// Legacy evaluator
var legacyException = default(Exception);
var legacyResult = default(TLegacy);
Expand Down Expand Up @@ -253,14 +256,18 @@ private TLegacy EvaluateAndCompare<TLegacy, TNew>(
newException = ex;
}

// Capture cancellation state after evaluation
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;

// Compare results or exceptions
bool hasMismatch = false;
if (legacyException != null || newException != null)
{
// Either one or both threw exceptions - compare them
if (!CompareExceptions(legacyException, newException))
{
_trace.Info($"{methodName} exception mismatch");
RecordMismatch($"{methodName}");
hasMismatch = true;
}
}
else
Expand All @@ -269,6 +276,20 @@ private TLegacy EvaluateAndCompare<TLegacy, TNew>(
if (!resultComparer(legacyResult, newResult))
{
_trace.Info($"{methodName} mismatch");
hasMismatch = true;
}
}

// Only record mismatch if it wasn't caused by a cancellation race condition
if (hasMismatch)
{
if (!cancellationRequestedBefore && cancellationRequestedAfter)
{
// Cancellation state changed during evaluation window - skip recording
_trace.Info($"{methodName} mismatch skipped due to cancellation race condition");
}
else
{
RecordMismatch($"{methodName}");
}
}
Expand Down Expand Up @@ -612,6 +633,13 @@ private bool CompareExceptions(Exception legacyException, Exception newException
return false;
}

// Check for known equivalent error patterns (e.g., JSON parse errors)
// where both parsers correctly reject invalid input but with different wording
if (IsKnownEquivalentErrorPattern(legacyException, newException))
{
return true;
}

// Compare exception messages recursively (including inner exceptions)
var legacyMessages = GetExceptionMessages(legacyException);
var newMessages = GetExceptionMessages(newException);
Expand All @@ -634,6 +662,41 @@ private bool CompareExceptions(Exception legacyException, Exception newException
return true;
}

/// <summary>
/// Checks if two exceptions match a known pattern where both parsers correctly reject
/// invalid input but with different error messages (e.g., JSON parse errors from fromJSON).
/// </summary>
private bool IsKnownEquivalentErrorPattern(Exception legacyException, Exception newException)
{
// Get all messages in the exception chain
var legacyMessages = string.Join(" | ", GetExceptionMessages(legacyException));
var newMessages = string.Join(" | ", GetExceptionMessages(newException));

// fromJSON('') - both parsers fail when parsing empty string as JSON
// The error messages differ but both indicate JSON parsing failure.
// Legacy throws raw JsonReaderException: "Error reading JToken from JsonReader..."
// New wraps it: "Error parsing fromJson" with inner JsonReaderException
// Both may be wrapped in TemplateValidationException: "The template is not valid..."
if (IsJsonParseError(legacyMessages) && IsJsonParseError(newMessages))
{
_trace.Info("CompareExceptions - both exceptions are JSON parse errors (semantically equivalent)");
return true;
}

return false;
}

/// <summary>
/// Checks if the exception message chain indicates a JSON parsing error.
/// </summary>
private bool IsJsonParseError(string messages)
{
// Common patterns for JSON parse errors from fromJSON function
return messages.Contains("Error reading JToken from JsonReader") ||
messages.Contains("Error parsing fromJson") ||
messages.Contains("JsonReaderException");
}

private IList<string> GetExceptionMessages(Exception ex)
{
var messages = new List<string>();
Expand Down
21 changes: 13 additions & 8 deletions src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references

using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -43,7 +43,7 @@ internal static WorkflowTemplate ConvertToWorkflow(
{
case WorkflowTemplateConstants.On:
var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value);
foreach(var item in inputTypes)
foreach (var item in inputTypes)
{
result.InputTypes.TryAdd(item.Key, item.Value);
}
Expand Down Expand Up @@ -432,7 +432,7 @@ internal static Snapshot ConvertToSnapshot(
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
return null;
}

return new Snapshot
{
ImageName = imageName,
Expand All @@ -445,7 +445,7 @@ private static bool IsSnapshotImageVersionValid(string versionString)
{
var versionSegments = versionString.Split(".");

if (versionSegments.Length != 2 ||
if (versionSegments.Length != 2 ||
!versionSegments[1].Equals("*") ||
!Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) ||
parsedMajor < 0)
Expand Down Expand Up @@ -1154,7 +1154,12 @@ internal static JobContainer ConvertToJobContainer(

if (String.IsNullOrEmpty(result.Image))
{
context.Error(value, "Container image cannot be empty");
// Only error during early validation (parse time)
// At runtime (expression evaluation), empty image = no container
if (isEarlyValidation)
{
context.Error(value, "Container image cannot be empty");
}
return null;
}

Expand Down Expand Up @@ -1838,9 +1843,9 @@ private static Permissions ConvertToPermissions(TemplateContext context, Templat
case "actions":
permissions.Actions = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "attestations":
permissions.Attestations = permissionLevel;
break;
Expand Down
Loading
Loading