mirror of
https://github.com/actions/runner.git
synced 2026-02-04 03:47:26 +08:00
Compare commits
6 Commits
dependabot
...
users/eric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd3f6054f | ||
|
|
3ffedabea3 | ||
|
|
3a80a78cae | ||
|
|
6822f4aba2 | ||
|
|
ad43c639cf | ||
|
|
5d4fb30d5b |
@@ -5,8 +5,8 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=29.0.2
|
||||
ARG BUILDX_VERSION=0.30.1
|
||||
ARG DOCKER_VERSION=29.2.0
|
||||
ARG BUILDX_VERSION=0.31.1
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
|
||||
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";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
|
||||
@@ -84,7 +84,8 @@ namespace GitHub.Runner.Worker
|
||||
"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");
|
||||
});
|
||||
@@ -165,9 +166,150 @@ namespace GitHub.Runner.Worker
|
||||
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
|
||||
|
||||
@@ -379,7 +379,14 @@ namespace GitHub.Runner.Worker
|
||||
{
|
||||
prefix = PipelineTemplateConstants.RunDisplayPrefix;
|
||||
var repositoryReference = action.Reference as RepositoryPathReference;
|
||||
var pathString = string.IsNullOrEmpty(repositoryReference.Path) ? string.Empty : $"/{repositoryReference.Path}";
|
||||
var pathString = string.Empty;
|
||||
if (!string.IsNullOrEmpty(repositoryReference.Path))
|
||||
{
|
||||
// For local actions (Name is empty), don't prepend "/" to avoid "/./"
|
||||
pathString = string.IsNullOrEmpty(repositoryReference.Name)
|
||||
? repositoryReference.Path
|
||||
: $"/{repositoryReference.Path}";
|
||||
}
|
||||
var repoString = string.IsNullOrEmpty(repositoryReference.Ref) ? $"{repositoryReference.Name}{pathString}" :
|
||||
$"{repositoryReference.Name}{pathString}@{repositoryReference.Ref}";
|
||||
tokenToParse = new StringToken(null, null, null, repoString);
|
||||
|
||||
@@ -499,7 +499,7 @@ namespace GitHub.Runner.Worker
|
||||
|
||||
PublishStepTelemetry();
|
||||
|
||||
if (_record.RecordType == "Task")
|
||||
if (_record.RecordType == ExecutionContextType.Task)
|
||||
{
|
||||
var stepResult = new StepResult
|
||||
{
|
||||
@@ -532,6 +532,25 @@ namespace GitHub.Runner.Worker
|
||||
Global.StepsResult.Add(stepResult);
|
||||
}
|
||||
|
||||
if (Global.Variables.GetBoolean(Constants.Runner.Features.SendJobLevelAnnotations) ?? false)
|
||||
{
|
||||
if (_record.RecordType == ExecutionContextType.Job)
|
||||
{
|
||||
_record.Issues?.ForEach(issue =>
|
||||
{
|
||||
var annotation = issue.ToAnnotation();
|
||||
if (annotation != null)
|
||||
{
|
||||
Global.JobAnnotations.Add(annotation.Value);
|
||||
if (annotation.Value.IsInfrastructureIssue && string.IsNullOrEmpty(Global.InfrastructureFailureCategory))
|
||||
{
|
||||
Global.InfrastructureFailureCategory = issue.Category;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Root != this)
|
||||
{
|
||||
// only dispose TokenSource for step level ExecutionContext
|
||||
|
||||
3
src/Runner.Worker/InternalsVisibleTo.cs
Normal file
3
src/Runner.Worker/InternalsVisibleTo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Test")]
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.Expressions2;
|
||||
@@ -222,6 +222,9 @@ namespace GitHub.Runner.Worker
|
||||
Func<TNew> newEvaluator,
|
||||
Func<TLegacy, TNew, bool> resultComparer)
|
||||
{
|
||||
// Capture cancellation state before evaluation
|
||||
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
|
||||
|
||||
// Legacy evaluator
|
||||
var legacyException = default(Exception);
|
||||
var legacyResult = default(TLegacy);
|
||||
@@ -253,14 +256,18 @@ namespace GitHub.Runner.Worker
|
||||
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
|
||||
@@ -269,6 +276,20 @@ namespace GitHub.Runner.Worker
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -612,6 +633,13 @@ namespace GitHub.Runner.Worker
|
||||
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);
|
||||
@@ -634,6 +662,41 @@ namespace GitHub.Runner.Worker
|
||||
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>();
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": "string",
|
||||
"options": "non-empty-string",
|
||||
"options": "string",
|
||||
"env": "container-env",
|
||||
"ports": "sequence-of-non-empty-string",
|
||||
"volumes": "sequence-of-non-empty-string",
|
||||
|
||||
@@ -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;
|
||||
@@ -43,7 +43,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -432,7 +432,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return new Snapshot
|
||||
{
|
||||
ImageName = imageName,
|
||||
@@ -445,7 +445,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
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)
|
||||
@@ -1154,7 +1154,12 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1838,9 +1843,9 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
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;
|
||||
|
||||
@@ -2593,7 +2593,7 @@
|
||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "non-empty-string",
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||
},
|
||||
"env": "container-env",
|
||||
|
||||
343
src/Test/L0/Worker/ActionManifestParserComparisonL0.cs
Normal file
343
src/Test/L0/Worker/ActionManifestParserComparisonL0.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
using GitHub.Actions.WorkflowParser;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using LegacyExpressions = GitHub.DistributedTask.Expressions2;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for parser comparison wrapper classes.
|
||||
/// </summary>
|
||||
public sealed class ActionManifestParserComparisonL0
|
||||
{
|
||||
private CancellationTokenSource _ecTokenSource;
|
||||
private Mock<IExecutionContext> _ec;
|
||||
private TestHostContext _hc;
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void ConvertToLegacySteps_ProducesCorrectSteps_WithExplicitPropertyMapping()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that ActionManifestManagerWrapper properly converts new steps to legacy format
|
||||
Setup();
|
||||
|
||||
// Enable comparison feature
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Register required services
|
||||
var legacyManager = new ActionManifestManagerLegacy();
|
||||
legacyManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
|
||||
|
||||
var newManager = new ActionManifestManager();
|
||||
newManager.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManager>(newManager);
|
||||
|
||||
var wrapper = new ActionManifestManagerWrapper();
|
||||
wrapper.Initialize(_hc);
|
||||
|
||||
var manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml");
|
||||
|
||||
// Act - Load through the wrapper (which internally converts)
|
||||
var result = wrapper.Load(_ec.Object, manifestPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType);
|
||||
|
||||
var compositeExecution = result.Execution as CompositeActionExecutionData;
|
||||
Assert.NotNull(compositeExecution);
|
||||
Assert.NotNull(compositeExecution.Steps);
|
||||
Assert.Equal(6, compositeExecution.Steps.Count);
|
||||
|
||||
// Verify steps are NOT null (this was the bug - JSON round-trip produced nulls)
|
||||
foreach (var step in compositeExecution.Steps)
|
||||
{
|
||||
Assert.NotNull(step);
|
||||
Assert.NotNull(step.Reference);
|
||||
Assert.IsType<GitHub.DistributedTask.Pipelines.ScriptReference>(step.Reference);
|
||||
}
|
||||
|
||||
// Verify step with condition
|
||||
var successStep = compositeExecution.Steps[2];
|
||||
Assert.Equal("success-conditional", successStep.ContextName);
|
||||
Assert.Equal("success()", successStep.Condition);
|
||||
|
||||
// Verify step with complex condition
|
||||
var lastStep = compositeExecution.Steps[5];
|
||||
Assert.Contains("inputs.exit-code == 1", lastStep.Condition);
|
||||
Assert.Contains("failure()", lastStep.Condition);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateJobContainer_EmptyImage_BothParsersReturnNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that both parsers return null for empty container image at runtime
|
||||
Setup();
|
||||
|
||||
var fileTable = new List<string>();
|
||||
|
||||
// Create legacy evaluator
|
||||
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
|
||||
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
|
||||
|
||||
// Create new evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
|
||||
|
||||
// Create a token representing an empty container image (simulates expression evaluated to empty string)
|
||||
var emptyImageToken = new StringToken(null, null, null, "");
|
||||
|
||||
var contextData = new DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
// Act - Call both evaluators
|
||||
var legacyResult = legacyEvaluator.EvaluateJobContainer(emptyImageToken, contextData, expressionFunctions);
|
||||
|
||||
// Convert token for new evaluator
|
||||
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.StringToken(null, null, null, "");
|
||||
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
|
||||
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||
|
||||
var newResult = newEvaluator.EvaluateJobContainer(newToken, newContextData, newExpressionFunctions);
|
||||
|
||||
// Assert - Both should return null for empty image (no container)
|
||||
Assert.Null(legacyResult);
|
||||
Assert.Null(newResult);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void FromJsonEmptyString_BothParsersFail_WithDifferentMessages()
|
||||
{
|
||||
// This test verifies that both parsers fail with different error messages when parsing fromJSON('')
|
||||
// The comparison layer should treat these as semantically equivalent (both are JSON parse errors)
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
|
||||
var fileTable = new List<string>();
|
||||
|
||||
// Create legacy evaluator
|
||||
var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter();
|
||||
var schema = PipelineTemplateSchemaFactory.GetSchema();
|
||||
var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable);
|
||||
|
||||
// Create new evaluator
|
||||
var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter();
|
||||
var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null);
|
||||
|
||||
// Create expression token for fromJSON('')
|
||||
var legacyToken = new BasicExpressionToken(null, null, null, "fromJson('')");
|
||||
var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken(null, null, null, "fromJson('')");
|
||||
|
||||
var contextData = new DictionaryContextData();
|
||||
var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
|
||||
|
||||
// Act - Both should throw
|
||||
Exception legacyException = null;
|
||||
Exception newException = null;
|
||||
|
||||
try
|
||||
{
|
||||
legacyEvaluator.EvaluateStepDisplayName(legacyToken, contextData, expressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
legacyException = ex;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
newEvaluator.EvaluateStepName(newToken, newContextData, newExpressionFunctions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
newException = ex;
|
||||
}
|
||||
|
||||
// Assert - Both threw exceptions
|
||||
Assert.NotNull(legacyException);
|
||||
Assert.NotNull(newException);
|
||||
|
||||
// Verify the error messages are different (which is why we need semantic comparison)
|
||||
Assert.NotEqual(legacyException.Message, newException.Message);
|
||||
|
||||
// Verify both are JSON parse errors (contain JSON-related error indicators)
|
||||
var legacyFullMsg = GetFullExceptionMessage(legacyException);
|
||||
var newFullMsg = GetFullExceptionMessage(newException);
|
||||
|
||||
// At least one should contain indicators of JSON parsing failure
|
||||
var legacyIsJsonError = legacyFullMsg.Contains("JToken") ||
|
||||
legacyFullMsg.Contains("JsonReader") ||
|
||||
legacyFullMsg.Contains("fromJson");
|
||||
var newIsJsonError = newFullMsg.Contains("JToken") ||
|
||||
newFullMsg.Contains("JsonReader") ||
|
||||
newFullMsg.Contains("fromJson");
|
||||
|
||||
Assert.True(legacyIsJsonError, $"Legacy exception should be JSON error: {legacyFullMsg}");
|
||||
Assert.True(newIsJsonError, $"New exception should be JSON error: {newFullMsg}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that mismatches are not recorded when cancellation state changes during evaluation
|
||||
Setup();
|
||||
|
||||
// Enable comparison feature
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Create the wrapper
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
|
||||
// Create a simple token for evaluation
|
||||
var token = new StringToken(null, null, null, "test-value");
|
||||
var contextData = new DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
// First evaluation without cancellation - should work normally
|
||||
var result1 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
|
||||
Assert.Equal("test-value", result1);
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
|
||||
// Now simulate a scenario where cancellation occurs during evaluation
|
||||
// Cancel the token before next evaluation
|
||||
_ecTokenSource.Cancel();
|
||||
|
||||
// Evaluate again - even if there were a mismatch, it should be skipped due to cancellation
|
||||
var result2 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
|
||||
Assert.Equal("test-value", result2);
|
||||
|
||||
// Verify no mismatch was recorded (cancellation race detection should have prevented it)
|
||||
// Note: In this test, both parsers return the same result, so there's no actual mismatch.
|
||||
// The cancellation race detection is a safeguard for when results differ due to timing.
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange - Test that no mismatch is recorded when both parsers return matching results
|
||||
Setup();
|
||||
|
||||
// Enable comparison feature
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
|
||||
|
||||
// Create the wrapper
|
||||
var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object);
|
||||
|
||||
// Create a simple token for evaluation
|
||||
var token = new StringToken(null, null, null, "test-value");
|
||||
var contextData = new DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
// Evaluation without cancellation - should work normally and not record mismatch for matching results
|
||||
var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions);
|
||||
Assert.Equal("test-value", result);
|
||||
|
||||
// Since both parsers return the same result, no mismatch should be recorded
|
||||
Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFullExceptionMessage(Exception ex)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
messages.Add(current.Message);
|
||||
current = current.InnerException;
|
||||
}
|
||||
return string.Join(" -> ", messages);
|
||||
}
|
||||
|
||||
private void Setup([CallerMemberName] string name = "")
|
||||
{
|
||||
_ecTokenSource?.Dispose();
|
||||
_ecTokenSource = new CancellationTokenSource();
|
||||
|
||||
_hc = new TestHostContext(this, name);
|
||||
|
||||
var expressionValues = new LegacyContextData.DictionaryContextData();
|
||||
var expressionFunctions = new List<LegacyExpressions.IFunctionInfo>();
|
||||
|
||||
_ec = new Mock<IExecutionContext>();
|
||||
_ec.Setup(x => x.Global)
|
||||
.Returns(new GlobalContext
|
||||
{
|
||||
FileTable = new List<String>(),
|
||||
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
|
||||
WriteDebug = true,
|
||||
});
|
||||
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(expressionValues);
|
||||
_ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions);
|
||||
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
|
||||
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
|
||||
}
|
||||
|
||||
private void Teardown()
|
||||
{
|
||||
_hc?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,94 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal("${{ matrix.node }}", _actionRunner.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateDisplayNameForLocalAction()
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
var actionId = Guid.NewGuid();
|
||||
var action = new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
|
||||
Path = "./"
|
||||
}
|
||||
};
|
||||
_actionRunner.Action = action;
|
||||
|
||||
// Act
|
||||
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
|
||||
|
||||
// Assert
|
||||
Assert.True(validDisplayName);
|
||||
Assert.True(updated);
|
||||
Assert.Equal("Run ./", _actionRunner.DisplayName); // NOT "Run /./"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateDisplayNameForLocalActionWithPath()
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
var actionId = Guid.NewGuid();
|
||||
var action = new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
|
||||
Path = "./.github/actions/my-action"
|
||||
}
|
||||
};
|
||||
_actionRunner.Action = action;
|
||||
|
||||
// Act
|
||||
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
|
||||
|
||||
// Assert
|
||||
Assert.True(validDisplayName);
|
||||
Assert.True(updated);
|
||||
Assert.Equal("Run ./.github/actions/my-action", _actionRunner.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void EvaluateDisplayNameForRemoteActionWithPath()
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
var actionId = Guid.NewGuid();
|
||||
var action = new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = "owner/repo",
|
||||
Path = "subdir",
|
||||
Ref = "v1"
|
||||
}
|
||||
};
|
||||
_actionRunner.Action = action;
|
||||
|
||||
// Act
|
||||
var validDisplayName = _actionRunner.EvaluateDisplayName(_context, _actionRunner.ExecutionContext, out bool updated);
|
||||
|
||||
// Assert
|
||||
Assert.True(validDisplayName);
|
||||
Assert.True(updated);
|
||||
Assert.Equal("Run owner/repo/subdir@v1", _actionRunner.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -459,7 +547,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
|
||||
_handlerFactory = new Mock<IHandlerFactory>();
|
||||
_defaultStepHost = new Mock<IDefaultStepHost>();
|
||||
|
||||
|
||||
var actionManifestLegacy = new ActionManifestManagerLegacy();
|
||||
actionManifestLegacy.Initialize(_hc);
|
||||
_hc.SetSingleton<IActionManifestManagerLegacy>(actionManifestLegacy);
|
||||
|
||||
Reference in New Issue
Block a user