#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;
using System.Globalization;
using System.Linq;
using GitHub.Actions.Expressions;
using GitHub.Actions.Expressions.Data;
using GitHub.Actions.Expressions.Sdk;
using GitHub.Actions.Expressions.Sdk.Functions;
using GitHub.Actions.WorkflowParser.ObjectTemplating;
using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens;
namespace GitHub.Actions.WorkflowParser.Conversion
{
internal static class WorkflowTemplateConverter
{
///
/// Constructs the . Errors are stored to both and .
///
internal static WorkflowTemplate ConvertToWorkflow(
TemplateContext context,
TemplateToken workflow)
{
var result = new WorkflowTemplate();
result.FileTable.AddRange(context.GetFileTable());
// Note, the "finally" block appends context.Errors to result
try
{
if (workflow == null || context.Errors.Count > 0)
{
return result;
}
var workflowMapping = workflow.AssertMapping("root");
foreach (var workflowPair in workflowMapping)
{
var workflowKey = workflowPair.Key.AssertString("root key");
switch (workflowKey.Value)
{
case WorkflowTemplateConstants.On:
var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value);
foreach(var item in inputTypes)
{
result.InputTypes.TryAdd(item.Key, item.Value);
}
break;
case WorkflowTemplateConstants.Description:
case WorkflowTemplateConstants.Name:
case WorkflowTemplateConstants.RunName:
break;
case WorkflowTemplateConstants.Defaults:
result.Defaults = workflowPair.Value;
break;
case WorkflowTemplateConstants.Env:
result.Env = workflowPair.Value;
break;
case WorkflowTemplateConstants.Concurrency:
ConvertToConcurrency(context, workflowPair.Value, isEarlyValidation: true);
result.Concurrency = workflowPair.Value;
break;
case WorkflowTemplateConstants.Jobs:
result.Jobs.AddRange(ConvertToJobs(context, workflowPair.Value));
break;
case WorkflowTemplateConstants.Permissions:
result.Permissions = ConvertToPermissions(context, workflowPair.Value);
break;
default:
workflowKey.AssertUnexpectedValue("root key"); // throws
break;
}
}
// Propagate explicit permissions
if (result.Permissions != null)
{
foreach (var job in result.Jobs)
{
if (job.Permissions == null)
{
job.Permissions = result.Permissions;
}
}
}
}
catch (Exception ex)
{
context.Errors.Add(ex);
}
finally
{
if (context.Errors.Count > 0)
{
foreach (var error in context.Errors)
{
result.Errors.Add(new WorkflowValidationError(error.Code, error.Message));
}
}
}
return result;
}
internal static void ConvertToReferencedWorkflow(
TemplateContext context,
TemplateToken referencedWorkflow,
ReusableWorkflowJob workflowJob,
String permissionsPolicy,
bool isTrusted)
{
// Explicit max permissions for the reusable workflow or reusable workflow chain.
// Present only when the caller (or higher ancestor) has defined the maximum allowed permissions in YAML.
var explicitMaxPermissions = workflowJob.Permissions;
var workflowMapping = referencedWorkflow.AssertMapping("root");
foreach (var workflowPair in workflowMapping)
{
var workflowKey = workflowPair.Key.AssertString("root key");
switch (workflowKey.Value)
{
case WorkflowTemplateConstants.On:
ConvertToOnTrigger(context, workflowPair.Value, workflowJob);
break;
case WorkflowTemplateConstants.Description:
case WorkflowTemplateConstants.Name:
case WorkflowTemplateConstants.RunName:
break;
case WorkflowTemplateConstants.Defaults:
workflowJob.Defaults = workflowPair.Value;
break;
case WorkflowTemplateConstants.Env:
workflowJob.Env = workflowPair.Value;
break;
case WorkflowTemplateConstants.Concurrency:
ConvertToConcurrency(context, workflowPair.Value, true);
workflowJob.EmbeddedConcurrency = workflowPair.Value;
break;
case WorkflowTemplateConstants.Jobs:
workflowJob.Jobs.AddRange(ConvertToJobs(context, workflowPair.Value));
break;
case WorkflowTemplateConstants.Permissions:
var embeddedRootPermissions = ConvertToPermissions(context, workflowPair.Value);
PermissionsHelper.ValidateEmbeddedPermissions(
context,
workflowJob,
embeddedJob: null,
requested: embeddedRootPermissions,
explicitMax: explicitMaxPermissions,
permissionsPolicy,
isTrusted);
workflowJob.Permissions = embeddedRootPermissions;
break;
default:
workflowKey.AssertUnexpectedValue("root key"); // throws
break;
}
}
// Validate requested permissions or propagate explicit permissions
foreach (var embeddedJob in workflowJob.Jobs)
{
if (embeddedJob.Permissions != null)
{
// Validate requested permissions
PermissionsHelper.ValidateEmbeddedPermissions(
context,
workflowJob,
embeddedJob,
requested: embeddedJob.Permissions,
explicitMax: explicitMaxPermissions,
permissionsPolicy,
isTrusted);
}
else if (workflowJob.Permissions != null)
{
// Propagate explicit permissions
embeddedJob.Permissions = workflowJob.Permissions;
}
}
}
internal static IDictionary ConvertToOnWorkflowDispatchInputTypes(TemplateToken onToken)
{
var result = new Dictionary();
if (onToken.Type != TokenType.Mapping)
{
return result;
}
var triggerMapping = onToken.AssertMapping($"workflow {WorkflowTemplateConstants.On} value");
var dispatchTrigger = triggerMapping.FirstOrDefault(x =>
string.Equals((x.Key as StringToken).Value, WorkflowTemplateConstants.WorkflowDispatch, StringComparison.Ordinal)).Value;
if (dispatchTrigger == null || dispatchTrigger is NullToken)
{
return result;
}
var wfDispatchDefinitions = dispatchTrigger.AssertMapping($"workflow {WorkflowTemplateConstants.On} value {WorkflowTemplateConstants.WorkflowDispatch}");
var inputDefinitionsToken = wfDispatchDefinitions.FirstOrDefault(x =>
string.Equals((x.Key as StringToken).Value, WorkflowTemplateConstants.Inputs, StringComparison.Ordinal)).Value;
if (inputDefinitionsToken == null || inputDefinitionsToken is NullToken)
{
return result;
}
var inputs = inputDefinitionsToken.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowDispatch}-{WorkflowTemplateConstants.Inputs}");
var inputDefinitions = inputs?
.ToDictionary(
x => x.Key.AssertString("inputs key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase
);
foreach (var definedItem in inputDefinitions)
{
string definedKey = definedItem.Key;
if (definedItem.Value is NullToken)
{
result.Add(definedKey, WorkflowTemplateConstants.TypeString);
continue;
}
var definedInputSpec = definedItem.Value.AssertMapping($"input {definedKey}").ToDictionary(
x => x.Key.AssertString($"input {definedKey} key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase);
if (definedInputSpec.TryGetValue(WorkflowTemplateConstants.Type, out TemplateToken inputTypeToken) &&
inputTypeToken is StringToken inputTypeStringToken)
{
result.Add(definedKey, inputTypeStringToken.Value);
}
else
{
result.Add(definedKey, WorkflowTemplateConstants.TypeString);
}
}
return result;
}
internal static void ConvertToOnTrigger(
TemplateContext context,
TemplateToken onToken,
ReusableWorkflowJob parentWorkflowJob)
{
switch (onToken.Type)
{
// check for on: workflow_call
case TokenType.String:
var result = onToken.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value");
if (result.Value == WorkflowTemplateConstants.WorkflowCall)
{
ValidateWorkflowJobTrigger(context, parentWorkflowJob);
return;
}
break;
// check for on: [push, workflow_call]
case TokenType.Sequence:
var triggers = onToken.AssertSequence($"Reference workflow {WorkflowTemplateConstants.On} value");
foreach (var triggerItem in triggers)
{
var triggerString = triggerItem.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem}").Value;
if (triggerString == WorkflowTemplateConstants.WorkflowCall)
{
ValidateWorkflowJobTrigger(context, parentWorkflowJob);
return;
}
}
break;
// check for on: workflow_call Mapping
case TokenType.Mapping:
var triggerMapping = onToken.AssertMapping($"Reference workflow {WorkflowTemplateConstants.On} value");
foreach (var triggerItem in triggerMapping)
{
var triggerString = triggerItem.Key.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem.Key}").Value;
if (triggerString == WorkflowTemplateConstants.WorkflowCall)
{
if (triggerItem.Value is NullToken)
{
ValidateWorkflowJobTrigger(context, parentWorkflowJob);
return;
}
var wfCallDefinitions = triggerItem.Value.AssertMapping($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem.Key}");
foreach (var wfCallDefinitionItem in wfCallDefinitions)
{
var wfCallDefinitionItemKey = wfCallDefinitionItem.Key.AssertString($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}").Value;
if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Inputs)
{
parentWorkflowJob.InputDefinitions = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}");
}
else if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Secrets)
{
parentWorkflowJob.SecretDefinitions = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}");
}
else if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Outputs)
{
parentWorkflowJob.Outputs = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}");
}
}
ValidateWorkflowJobTrigger(context, parentWorkflowJob);
return;
}
}
break;
default:
break;
}
context.Error(onToken, $"{WorkflowTemplateConstants.WorkflowCall} key is not defined in the referenced workflow.");
return;
}
internal static Boolean ConvertToIfResult(
TemplateContext context,
TemplateToken ifResult)
{
var expression = ifResult.Traverse().FirstOrDefault(x => x is ExpressionToken);
if (expression != null)
{
throw new ArgumentException($"Unexpected type '{expression.GetType().Name}' encountered while reading 'if'.");
}
var evaluationResult = EvaluationResult.CreateIntermediateResult(null, ifResult);
return evaluationResult.IsTruthy;
}
internal static String ConvertToJobName(
TemplateContext context,
TemplateToken name,
Boolean isEarlyValidation = false)
{
var result = default(String);
// Expression
if (isEarlyValidation && name is ExpressionToken)
{
return result;
}
// String
var nameString = name.AssertString($"job {WorkflowTemplateConstants.Name}");
result = nameString.Value;
return result;
}
internal static Snapshot ConvertToSnapshot(
TemplateContext context,
TemplateToken snapshotToken,
Boolean isEarlyValidation = false)
{
String imageName = null;
string defaultVersion = "1.*";
String version = null;
var condition = new BasicExpressionToken(null, null, null, $"{WorkflowTemplateConstants.Success}()");
if (isEarlyValidation && snapshotToken is ExpressionToken)
{
return default;
}
// String
if (snapshotToken is StringToken snapshotStringToken)
{
imageName = snapshotStringToken.Value;
}
// Mapping
else if (snapshotToken is MappingToken snapshotMappingToken)
{
foreach (var snapshotProperty in snapshotMappingToken)
{
var propertyName = snapshotProperty.Key.AssertString($"job {WorkflowTemplateConstants.Snapshot} key");
var propertyValue = snapshotProperty.Value;
switch (propertyName.Value)
{
case WorkflowTemplateConstants.ImageName:
if (isEarlyValidation && propertyValue is ExpressionToken)
{
return default;
}
imageName = propertyValue.AssertString($"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName}").Value;
break;
case WorkflowTemplateConstants.If:
condition = ConvertToIfCondition(context, propertyValue, IfKind.Snapshot);
break;
case WorkflowTemplateConstants.CustomImageVersion:
if (isEarlyValidation && propertyValue is ExpressionToken)
{
return default;
}
var versionValue = propertyValue.AssertString($"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.CustomImageVersion}").Value;
if (versionValue != null && !IsSnapshotImageVersionValid(versionValue))
{
context.Error(snapshotToken, "Expected format '{major-version}.*' Actual '" + versionValue + "'");
return null;
}
version = versionValue;
break;
default:
propertyName.AssertUnexpectedValue($"job {WorkflowTemplateConstants.Snapshot} key");
break;
}
}
}
// ImageName is a required property (schema validation)
if (imageName == null)
{
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
return null;
}
return new Snapshot
{
ImageName = imageName,
If = condition,
Version = version ?? defaultVersion
};
}
private static bool IsSnapshotImageVersionValid(string versionString)
{
var versionSegments = versionString.Split(".");
if (versionSegments.Length != 2 ||
!versionSegments[1].Equals("*") ||
!Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) ||
parsedMajor < 0)
{
return false;
}
return true;
}
internal static RunsOn ConvertToRunsOn(
TemplateContext context,
TemplateToken runsOn,
Boolean isEarlyValidation = false)
{
var result = new RunsOn();
ConvertToRunsOnLabels(context, runsOn, result, isEarlyValidation);
// Mapping
if (runsOn is MappingToken runsOnMapping)
{
foreach (var runsOnToken in runsOnMapping)
{
var propertyName = runsOnToken.Key.AssertString($"job {WorkflowTemplateConstants.RunsOn} property name");
switch (propertyName.Value)
{
case WorkflowTemplateConstants.Group:
// Expression
if (isEarlyValidation && runsOnToken.Value is ExpressionToken)
{
continue;
}
// String
var groupName = runsOnToken.Value.AssertString($"job {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name").Value;
var names = groupName.Split(WorkflowTemplateConstants.Slash);
if (names.Length == 2)
{
if (string.IsNullOrEmpty(names[1]))
{
context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'.");
}
else if (!string.Equals(names[0], WorkflowTemplateConstants.Org) && !string.Equals(names[0], WorkflowTemplateConstants.Organization) &&
!string.Equals(names[0], WorkflowTemplateConstants.Ent) && !string.Equals(names[0], WorkflowTemplateConstants.Enterprise))
{
context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.");
}
else
{
result.RunnerGroup = groupName;
}
}
else if (names.Length > 2)
{
context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.");
}
else
{
result.RunnerGroup = groupName;
}
break;
case WorkflowTemplateConstants.Labels:
ConvertToRunsOnLabels(context, runsOnToken.Value, result, isEarlyValidation);
break;
}
}
}
return result;
}
internal static void ConvertToRunsOnLabels(
TemplateContext context,
TemplateToken runsOnLabelsToken,
RunsOn runsOn,
Boolean isEarlyValidation = false)
{
// Expression
if (isEarlyValidation && runsOnLabelsToken is ExpressionToken)
{
return;
}
// String
if (runsOnLabelsToken is StringToken runsOnLabelsString)
{
runsOn.Labels.Add(runsOnLabelsString.Value);
}
// Sequence
else if (runsOnLabelsToken is SequenceToken runsOnLabelsSequence)
{
foreach (var runsOnLabelToken in runsOnLabelsSequence)
{
// Expression
if (isEarlyValidation && runsOnLabelToken is ExpressionToken)
{
continue;
}
// String
var label = runsOnLabelToken.AssertString($"job {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Labels} sequence item");
runsOn.Labels.Add(label.Value);
}
}
}
internal static Int32? ConvertToJobTimeout(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var numberToken = token.AssertNumber($"job {WorkflowTemplateConstants.TimeoutMinutes}");
return (Int32)numberToken.Value;
}
internal static Int32? ConvertToJobCancelTimeout(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var numberToken = token.AssertNumber($"job {WorkflowTemplateConstants.CancelTimeoutMinutes}");
return (Int32)numberToken.Value;
}
internal static Boolean? ConvertToJobContinueOnError(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var booleanToken = token.AssertBoolean($"job {WorkflowTemplateConstants.ContinueOnError}");
return booleanToken.Value;
}
internal static Boolean? ConvertToStepContinueOnError(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var booleanToken = token.AssertBoolean($"step {WorkflowTemplateConstants.ContinueOnError}");
return booleanToken.Value;
}
internal static String ConvertToStepName(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var stringToken = token.AssertString($"step {WorkflowTemplateConstants.Name}");
return stringToken.Value;
}
internal static GroupPermitSetting ConvertToConcurrency(
TemplateContext context,
TemplateToken concurrency,
Boolean isEarlyValidation = false)
{
var result = new GroupPermitSetting("");
// Expression
if (isEarlyValidation && concurrency is ExpressionToken)
{
return result;
}
// String
if (concurrency is StringToken concurrencyString)
{
result.Group = concurrencyString.Value;
}
// Mapping
else
{
var concurrencyMapping = concurrency.AssertMapping($"{WorkflowTemplateConstants.Concurrency}");
foreach (var concurrencyProperty in concurrencyMapping)
{
var propertyName = concurrencyProperty.Key.AssertString($"{WorkflowTemplateConstants.Concurrency} key");
// Expression
if (isEarlyValidation && (concurrencyProperty.Value is ExpressionToken || concurrencyProperty.Key is ExpressionToken))
{
continue;
}
switch (propertyName.Value)
{
case WorkflowTemplateConstants.Group:
// Literal
var group = concurrencyProperty.Value.AssertString($"{WorkflowTemplateConstants.Group} key");
result.Group = group.Value;
break;
case WorkflowTemplateConstants.CancelInProgress:
// Literal
var cancelInProgress = concurrencyProperty.Value.AssertBoolean($"{WorkflowTemplateConstants.CancelInProgress} key");
result.CancelInProgress = cancelInProgress.Value;
break;
default:
propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Concurrency} key"); // throws
break;
}
}
}
if (!isEarlyValidation && String.IsNullOrEmpty(result.Group))
{
context.Error(concurrency, "Concurrency group name cannot be empty");
}
if (result.Group?.Length > 400)
{
context.Error(concurrency, "Concurrency group name must be less than 400 characters");
}
return result;
}
internal static ActionsEnvironmentReference ConvertToActionEnvironmentReference(
TemplateContext context,
TemplateToken environment,
bool isEarlyValidation = false)
{
// Expression
if (isEarlyValidation && environment is ExpressionToken)
{
return null;
}
// String
else if (environment is StringToken nameString)
{
return String.IsNullOrEmpty(nameString.Value) ? null : new ActionsEnvironmentReference(nameString.Value);
}
// Mapping
else
{
var environmentMapping = environment.AssertMapping($"job {WorkflowTemplateConstants.Environment}");
if (isEarlyValidation)
{
// Skip early validation if any expressions other than "url" (expanded by the runner)
var urlToken = environmentMapping
.Where(x => x.Key is StringToken key && string.Equals(key.Value, WorkflowTemplateConstants.Url, StringComparison.Ordinal))
.Select(x => x.Value)
.SingleOrDefault();
if (isEarlyValidation && environmentMapping.Traverse().Any(x => x is ExpressionToken && x != urlToken))
{
return null;
}
}
var name = default(String);
var url = default(TemplateToken);
foreach (var environmentProp in environmentMapping)
{
var propertyName = environmentProp.Key.AssertString($"job {WorkflowTemplateConstants.Environment} key");
var propertyValue = environmentProp.Value;
switch (propertyName.Value)
{
// Name is a required property (schema validation)
case WorkflowTemplateConstants.Name:
name = propertyValue.AssertString($"job {WorkflowTemplateConstants.Environment} {WorkflowTemplateConstants.Name}").Value;
break;
case WorkflowTemplateConstants.Url:
url = propertyValue;
break;
default:
propertyName.AssertUnexpectedValue($"job {WorkflowTemplateConstants.Environment} key"); // throws
break;
}
}
if (!String.IsNullOrEmpty(name))
{
return new ActionsEnvironmentReference(name) { Url = url };
}
else
{
return null;
}
}
}
internal static Dictionary ConvertToStepEnvironment(
TemplateContext context,
TemplateToken environment,
StringComparer keyComparer,
Boolean isEarlyValidation = false)
{
var result = new Dictionary(keyComparer);
// Expression
if (isEarlyValidation && environment is ExpressionToken)
{
return result;
}
// Mapping
var mapping = environment.AssertMapping("environment");
foreach (var pair in mapping)
{
// Expression key
if (isEarlyValidation && pair.Key is ExpressionToken)
{
continue;
}
// String key
var key = pair.Key.AssertString("environment key");
// Expression value
if (isEarlyValidation && pair.Value is ExpressionToken)
{
continue;
}
// String value
var value = pair.Value.AssertString("environment value");
result[key.Value] = value.Value;
}
return result;
}
internal static Dictionary ConvertToStepInputs(
TemplateContext context,
TemplateToken inputs,
Boolean isEarlyValidation = false)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Expression
if (isEarlyValidation && inputs is ExpressionToken)
{
return result;
}
// Mapping
var mapping = inputs.AssertMapping("inputs");
foreach (var pair in mapping)
{
// Expression key
if (isEarlyValidation && pair.Key is ExpressionToken)
{
continue;
}
// Literal key
var key = pair.Key.AssertString("inputs key");
// Expression value
if (isEarlyValidation && pair.Value is ExpressionToken)
{
continue;
}
// Literal value
var value = pair.Value.AssertString("inputs value");
result[key.Value] = value.Value;
}
return result;
}
internal static Int32? ConvertToStepTimeout(
TemplateContext context,
TemplateToken token,
Boolean isEarlyValidation = false)
{
if (isEarlyValidation && token is ExpressionToken)
{
return null;
}
var numberToken = token.AssertNumber($"step {WorkflowTemplateConstants.TimeoutMinutes}");
return (Int32)numberToken.Value;
}
internal static Strategy ConvertToStrategy(
TemplateContext context,
TemplateToken token,
String jobFactoryName,
Boolean isEarlyValidation = false)
{
var result = new Strategy();
// Expression
if (isEarlyValidation && token is ExpressionToken)
{
return result;
}
var strategyMapping = token.AssertMapping(WorkflowTemplateConstants.Strategy);
var matrixBuilder = default(MatrixBuilder);
var hasExpressions = false;
foreach (var strategyPair in strategyMapping)
{
// Expression key
if (isEarlyValidation && strategyPair.Key is ExpressionToken)
{
hasExpressions = true;
continue;
}
// Literal key
var strategyKey = strategyPair.Key.AssertString("strategy key");
switch (strategyKey.Value)
{
// Fail-Fast
case WorkflowTemplateConstants.FailFast:
if (isEarlyValidation && strategyPair.Value is ExpressionToken)
{
hasExpressions = true;
continue;
}
var failFastBooleanToken = strategyPair.Value.AssertBoolean($"strategy {WorkflowTemplateConstants.FailFast}");
result.FailFast = failFastBooleanToken.Value;
break;
// Max-Parallel
case WorkflowTemplateConstants.MaxParallel:
if (isEarlyValidation && strategyPair.Value is ExpressionToken)
{
hasExpressions = true;
continue;
}
var maxParallelNumberToken = strategyPair.Value.AssertNumber($"strategy {WorkflowTemplateConstants.MaxParallel}");
result.MaxParallel = (Int32)maxParallelNumberToken.Value;
break;
// Matrix
case WorkflowTemplateConstants.Matrix:
// Expression
if (isEarlyValidation && strategyPair.Value is ExpressionToken)
{
hasExpressions = true;
continue;
}
var matrix = strategyPair.Value.AssertMapping("matrix");
hasExpressions = hasExpressions || matrix.Traverse().Any(x => x is ExpressionToken);
matrixBuilder = new MatrixBuilder(context, jobFactoryName);
var hasCrossProductVector = false;
var hasIncludeVector = false;
foreach (var matrixPair in matrix)
{
// Expression key
if (isEarlyValidation && matrixPair.Key is ExpressionToken)
{
hasCrossProductVector = true; // For early validation, treat as if a vector is defined
continue;
}
var matrixKey = matrixPair.Key.AssertString("matrix key");
switch (matrixKey.Value)
{
case WorkflowTemplateConstants.Include:
if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken))
{
hasIncludeVector = true; // For early validation, treat as OK
continue;
}
var includeSequence = matrixPair.Value.AssertSequence("matrix includes");
hasIncludeVector = includeSequence.Count > 0;
matrixBuilder.Include(includeSequence);
break;
case WorkflowTemplateConstants.Exclude:
if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken))
{
continue;
}
var excludeSequence = matrixPair.Value.AssertSequence("matrix excludes");
matrixBuilder.Exclude(excludeSequence);
break;
default:
hasCrossProductVector = true;
if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken))
{
continue;
}
var vectorName = matrixKey.Value;
var vectorSequence = matrixPair.Value.AssertSequence("matrix vector value");
if (vectorSequence.Count == 0)
{
context.Error(vectorSequence, $"Matrix vector '{vectorName}' does not contain any values");
}
else
{
matrixBuilder.AddVector(vectorName, vectorSequence);
}
break;
}
}
if (!hasCrossProductVector && !hasIncludeVector)
{
context.Error(matrix, $"Matrix must define at least one vector");
}
break;
default:
strategyKey.AssertUnexpectedValue("strategy key"); // throws
break;
}
}
if (hasExpressions)
{
return result;
}
if (matrixBuilder != null)
{
result.Configurations.AddRange(matrixBuilder.Build());
}
for (var i = 0; i < result.Configurations.Count; i++)
{
var configuration = result.Configurations[i];
var strategy = new DictionaryExpressionData()
{
{
"fail-fast",
new BooleanExpressionData(result.FailFast)
},
{
"job-index",
new NumberExpressionData(i)
},
{
"job-total",
new NumberExpressionData(result.Configurations.Count)
}
};
if (result.MaxParallel > 0)
{
strategy.Add(
"max-parallel",
new NumberExpressionData(result.MaxParallel)
);
}
else
{
strategy.Add(
"max-parallel",
new NumberExpressionData(result.Configurations.Count)
);
}
configuration.ExpressionData.Add(WorkflowTemplateConstants.Strategy, strategy);
context.Memory.AddBytes(WorkflowTemplateConstants.Strategy);
context.Memory.AddBytes(strategy, traverse: true);
if (!configuration.ExpressionData.ContainsKey(WorkflowTemplateConstants.Matrix))
{
configuration.ExpressionData.Add(WorkflowTemplateConstants.Matrix, null);
context.Memory.AddBytes(WorkflowTemplateConstants.Matrix);
}
}
return result;
}
internal static ContainerRegistryCredentials ConvertToContainerCredentials(TemplateToken token)
{
var credentials = token.AssertMapping(WorkflowTemplateConstants.Credentials);
var result = new ContainerRegistryCredentials();
foreach (var credentialProperty in credentials)
{
var propertyName = credentialProperty.Key.AssertString($"{WorkflowTemplateConstants.Credentials} key");
switch (propertyName.Value)
{
case WorkflowTemplateConstants.Username:
result.Username = credentialProperty.Value.AssertString(WorkflowTemplateConstants.Username).Value;
break;
case WorkflowTemplateConstants.Password:
result.Password = credentialProperty.Value.AssertString(WorkflowTemplateConstants.Password).Value;
break;
default:
propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Credentials} key {propertyName}");
break;
}
}
return result;
}
internal static JobContainer ConvertToJobContainer(
TemplateContext context,
TemplateToken value,
bool isEarlyValidation = false)
{
var result = new JobContainer();
if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken))
{
return result;
}
if (value is StringToken containerLiteral)
{
if (String.IsNullOrEmpty(containerLiteral.Value))
{
return null;
}
result.Image = containerLiteral.Value;
}
else
{
var containerMapping = value.AssertMapping($"{WorkflowTemplateConstants.Container}");
foreach (var containerPropertyPair in containerMapping)
{
var propertyName = containerPropertyPair.Key.AssertString($"{WorkflowTemplateConstants.Container} key");
switch (propertyName.Value)
{
case WorkflowTemplateConstants.Image:
result.Image = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
break;
case WorkflowTemplateConstants.Env:
var env = containerPropertyPair.Value.AssertMapping($"{WorkflowTemplateConstants.Container} {propertyName}");
var envDict = new Dictionary(env.Count);
foreach (var envPair in env)
{
var envKey = envPair.Key.ToString();
var envValue = envPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {envPair.Key.ToString()}").Value;
envDict.Add(envKey, envValue);
}
result.Environment = envDict;
break;
case WorkflowTemplateConstants.Options:
result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value;
break;
case WorkflowTemplateConstants.Ports:
var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
var portList = new List(ports.Count);
foreach (var portItem in ports)
{
var portString = portItem.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {portItem.ToString()}").Value;
portList.Add(portString);
}
result.Ports = portList;
break;
case WorkflowTemplateConstants.Volumes:
var volumes = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}");
var volumeList = new List(volumes.Count);
foreach (var volumeItem in volumes)
{
var volumeString = volumeItem.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {volumeItem.ToString()}").Value;
volumeList.Add(volumeString);
}
result.Volumes = volumeList;
break;
case WorkflowTemplateConstants.Credentials:
result.Credentials = ConvertToContainerCredentials(containerPropertyPair.Value);
break;
default:
propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Container} key");
break;
}
}
}
if (String.IsNullOrEmpty(result.Image))
{
context.Error(value, "Container image cannot be empty");
return null;
}
if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
return result;
}
internal static List> ConvertToJobServiceContainers(
TemplateContext context,
TemplateToken services,
bool isEarlyValidation = false)
{
var result = new List>();
if (isEarlyValidation && services.Traverse().Any(x => x is ExpressionToken))
{
return result;
}
var servicesMapping = services.AssertMapping("services");
foreach (var servicePair in servicesMapping)
{
var networkAlias = servicePair.Key.AssertString("services key").Value;
var container = ConvertToJobContainer(context, servicePair.Value);
result.Add(new KeyValuePair(networkAlias, container));
}
return result;
}
private static IList ConvertToJobs(
TemplateContext context,
TemplateToken workflow)
{
var result = new List();
var jobsMapping = workflow.AssertMapping(WorkflowTemplateConstants.Jobs);
var ready = new Queue();
var allUnsatisfied = new List();
var jobCountValidator = context.GetJobCountValidator();
foreach (var jobsPair in jobsMapping)
{
var jobId = jobsPair.Key.AssertString($"{WorkflowTemplateConstants.Jobs} key");
jobCountValidator.Increment(jobId);
var jobDefinition = jobsPair.Value.AssertMapping($"{WorkflowTemplateConstants.Jobs} value");
var idBuilder = new IdBuilder();
var job = ConvertToJob(context, jobId, jobDefinition, idBuilder);
result.Add(job);
var nodeInfo = new NodeInfo
{
Name = job.Id!.Value,
Needs = new List(job.Needs ?? new List()),
};
if (nodeInfo.Needs.Count == 0)
{
ready.Enqueue(nodeInfo);
}
else
{
allUnsatisfied.Add(nodeInfo);
}
}
if (context.Errors.Count != 0)
{
return result;
}
if (ready.Count == 0)
{
context.Error(jobsMapping, "The workflow must contain at least one job with no dependencies.");
return result;
}
while (ready.Count > 0)
{
var current = ready.Dequeue();
// Figure out which nodes would start after current completes
for (var i = allUnsatisfied.Count - 1; i >= 0; i--)
{
var unsatisfied = allUnsatisfied[i];
for (var j = unsatisfied.Needs.Count - 1; j >= 0; j--)
{
if (String.Equals(unsatisfied.Needs[j].Value, current.Name, StringComparison.OrdinalIgnoreCase))
{
unsatisfied.Needs.RemoveAt(j);
if (unsatisfied.Needs.Count == 0)
{
ready.Enqueue(unsatisfied);
allUnsatisfied.RemoveAt(i);
}
}
}
}
}
// Check whether some jobs will never execute
if (allUnsatisfied.Count > 0)
{
var names = result.ToHashSet(x => x.Id!.Value, StringComparer.OrdinalIgnoreCase);
foreach (var unsatisfied in allUnsatisfied)
{
foreach (var need in unsatisfied.Needs)
{
if (names.Contains(need.Value))
{
context.Error(need, $"Job '{unsatisfied.Name}' depends on job '{need.Value}' which creates a cycle in the dependency graph.");
}
else
{
context.Error(need, $"Job '{unsatisfied.Name}' depends on unknown job '{need.Value}'.");
}
}
}
}
return result;
}
private static IJob ConvertToJob(
TemplateContext context,
StringToken jobId,
MappingToken jobDefinition,
IdBuilder idBuilder)
{
if (!idBuilder.TryAddKnownId(jobId.Value, out var error))
{
context.Error(jobId, error);
}
var condition = new BasicExpressionToken(null, null, null, $"{WorkflowTemplateConstants.Success}()");
var continueOnError = default(ScalarToken);
var env = default(TemplateToken);
var name = default(ScalarToken);
var jobTarget = default(TemplateToken);
var steps = new List();
var strategy = default(TemplateToken);
var jobContainer = default(TemplateToken);
var jobServiceContainers = default(TemplateToken);
var concurrency = default(TemplateToken);
var actionsEnvironment = default(TemplateToken);
var defaults = default(TemplateToken);
var permissions = default(Permissions);
var outputs = default(TemplateToken);
var jobTimeout = default(ScalarToken);
var jobCancelTimeout = default(ScalarToken);
var snapshot = default(TemplateToken);
var needs = new List();
var workflowJobRef = default(StringToken);
var workflowJobInputs = default(MappingToken);
var workflowJobSecrets = default(MappingToken);
var workflowJobSecretsInherited = false;
foreach (var jobProperty in jobDefinition)
{
var propertyName = jobProperty.Key.AssertString($"job property name");
switch (propertyName.Value)
{
case WorkflowTemplateConstants.ContinueOnError:
ConvertToJobContinueOnError(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
continueOnError = jobProperty.Value.AssertScalar($"job {WorkflowTemplateConstants.ContinueOnError}");
break;
case WorkflowTemplateConstants.If:
condition = ConvertToIfCondition(context, jobProperty.Value, IfKind.Job);
break;
case WorkflowTemplateConstants.Name:
name = jobProperty.Value.AssertScalar($"job {WorkflowTemplateConstants.Name}");
ConvertToJobName(context, name, isEarlyValidation: true); // Validate early if possible
break;
case WorkflowTemplateConstants.Needs:
if (jobProperty.Value is StringToken needsLiteral)
{
needs.Add(needsLiteral);
}
else
{
var needsSeq = jobProperty.Value.AssertSequence($"job {WorkflowTemplateConstants.Needs}");
foreach (var needsItem in needsSeq)
{
var need = needsItem.AssertString($"job {WorkflowTemplateConstants.Needs} item");
needs.Add(need);
}
}
break;
case WorkflowTemplateConstants.RunsOn:
ConvertToRunsOn(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
jobTarget = jobProperty.Value;
break;
case WorkflowTemplateConstants.Snapshot:
if (!context.GetFeatures().Snapshot)
{
context.Error(jobProperty.Key, $"The key '{WorkflowTemplateConstants.Snapshot}' is not allowed");
break;
}
ConvertToSnapshot(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
snapshot = jobProperty.Value;
break;
case WorkflowTemplateConstants.Steps:
steps.AddRange(ConvertToSteps(context, jobProperty.Value));
break;
case WorkflowTemplateConstants.Strategy:
ConvertToStrategy(context, jobProperty.Value, null, isEarlyValidation: true); // Validate early if possible
strategy = jobProperty.Value;
break;
case WorkflowTemplateConstants.TimeoutMinutes:
ConvertToJobTimeout(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
jobTimeout = jobProperty.Value as ScalarToken;
break;
case WorkflowTemplateConstants.CancelTimeoutMinutes:
ConvertToJobCancelTimeout(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
jobCancelTimeout = jobProperty.Value as ScalarToken;
break;
case WorkflowTemplateConstants.Container:
ConvertToJobContainer(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
jobContainer = jobProperty.Value;
break;
case WorkflowTemplateConstants.Services:
ConvertToJobServiceContainers(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
jobServiceContainers = jobProperty.Value;
break;
case WorkflowTemplateConstants.Concurrency:
ConvertToConcurrency(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
concurrency = jobProperty.Value;
break;
case WorkflowTemplateConstants.Env:
env = jobProperty.Value;
break;
case WorkflowTemplateConstants.Environment:
ConvertToActionEnvironmentReference(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible
actionsEnvironment = jobProperty.Value;
break;
case WorkflowTemplateConstants.Outputs:
outputs = jobProperty.Value;
break;
case WorkflowTemplateConstants.Defaults:
defaults = jobProperty.Value;
break;
case WorkflowTemplateConstants.Permissions:
permissions = ConvertToPermissions(context, jobProperty.Value);
break;
case WorkflowTemplateConstants.Uses:
workflowJobRef = jobProperty.Value.AssertString($"job {WorkflowTemplateConstants.Uses} value");
break;
case WorkflowTemplateConstants.With:
workflowJobInputs = jobProperty.Value.AssertMapping($"{WorkflowTemplateConstants.Uses}-{WorkflowTemplateConstants.With} value");
break;
case WorkflowTemplateConstants.Secrets:
// String in case inherit is used
if (jobProperty.Value is StringToken sToken
&& sToken.Value == WorkflowTemplateConstants.Inherit)
{
workflowJobSecretsInherited = true;
}
else
{
workflowJobSecrets = jobProperty.Value.AssertMapping($"{WorkflowTemplateConstants.Uses}-{WorkflowTemplateConstants.Secrets} value");
}
break;
default:
propertyName.AssertUnexpectedValue("job key"); // throws
break;
}
}
if (workflowJobRef != null)
{
var workflowJob = new ReusableWorkflowJob
{
Id = jobId,
Name = name,
If = condition,
Ref = workflowJobRef,
InputValues = workflowJobInputs,
SecretValues = workflowJobSecrets,
InheritSecrets = workflowJobSecretsInherited,
Permissions = permissions,
Concurrency = concurrency,
Strategy = strategy,
};
if (workflowJob.Name is null || workflowJob.Name is StringToken nameStr && String.IsNullOrEmpty(nameStr.Value))
{
workflowJob.Name = workflowJob.Id;
}
workflowJob.Needs.AddRange(needs);
return workflowJob;
}
else
{
var result = new Job
{
Id = jobId,
Name = name,
ContinueOnError = continueOnError,
If = condition,
RunsOn = jobTarget,
Strategy = strategy,
TimeoutMinutes = jobTimeout,
CancelTimeoutMinutes = jobCancelTimeout,
Container = jobContainer,
Services = jobServiceContainers,
Concurrency = concurrency,
Env = env,
Environment = actionsEnvironment,
Outputs = outputs,
Defaults = defaults,
Permissions = permissions,
Snapshot = snapshot,
};
if (result.Name is null || result.Name is StringToken nameStr && String.IsNullOrEmpty(nameStr.Value))
{
result.Name = result.Id;
}
result.Needs.AddRange(needs);
result.Steps.AddRange(steps);
return result;
}
}
internal static IDictionary ConvertToWorkflowJobSecrets(
TemplateContext context,
TemplateToken secrets)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
var mapping = secrets.AssertMapping("workflow job secrets");
foreach (var pair in mapping)
{
var key = pair.Key.AssertString("workflow job secret key").Value;
var value = pair.Value.AssertString("workflow job secret value").Value;
if (!String.IsNullOrEmpty(value))
{
result[key] = value;
}
}
return result;
}
// Public because used by runner for composite actions
public static List ConvertToSteps(
TemplateContext context,
TemplateToken steps)
{
var stepsSequence = steps.AssertSequence($"job {WorkflowTemplateConstants.Steps}");
var idBuilder = new IdBuilder();
var result = stepsSequence.Select(x => ConvertToStep(context, x, idBuilder)).ToList();
// Generate default IDs when empty
foreach (IStep step in result)
{
if (!string.IsNullOrEmpty(step.Id))
{
continue;
}
var id = default(string);
if (step is ActionStep action)
{
if (action.Uses!.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal))
{
id = action.Uses!.Value.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length);
}
else if (action.Uses!.Value.StartsWith("./") || action.Uses!.Value.StartsWith(".\\"))
{
id = WorkflowConstants.SelfAlias;
}
else
{
var usesSegments = action.Uses!.Value.Split('@');
var pathSegments = usesSegments[0].Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
var gitRef = usesSegments.Length == 2 ? usesSegments[1] : String.Empty;
if (usesSegments.Length == 2 &&
pathSegments.Length >= 2 &&
!string.IsNullOrEmpty(pathSegments[0]) &&
!string.IsNullOrEmpty(pathSegments[1]) &&
!string.IsNullOrEmpty(gitRef))
{
id = $"{pathSegments[0]}/{pathSegments[1]}";
}
}
}
if (string.IsNullOrEmpty(id))
{
id = "run";
}
idBuilder.AppendSegment($"__{id}");
// Allow reserved prefix "__" for default IDs
step.Id = idBuilder.Build(allowReservedPrefix: true);
}
return result;
}
private static IStep ConvertToStep(
TemplateContext context,
TemplateToken stepsItem,
IdBuilder idBuilder)
{
var step = stepsItem.AssertMapping($"{WorkflowTemplateConstants.Steps} item");
var continueOnError = default(ScalarToken);
var env = default(TemplateToken);
var id = default(StringToken);
var ifCondition = default(BasicExpressionToken);
var ifToken = default(ScalarToken);
var name = default(ScalarToken);
var run = default(ScalarToken);
var timeoutMinutes = default(ScalarToken);
var uses = default(StringToken);
var with = default(TemplateToken);
var workingDir = default(ScalarToken);
var shell = default(ScalarToken);
foreach (var stepProperty in step)
{
var propertyName = stepProperty.Key.AssertString($"{WorkflowTemplateConstants.Steps} item key");
switch (propertyName.Value)
{
case WorkflowTemplateConstants.ContinueOnError:
ConvertToStepContinueOnError(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible
continueOnError = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} {WorkflowTemplateConstants.ContinueOnError}");
break;
case WorkflowTemplateConstants.Env:
ConvertToStepEnvironment(context, stepProperty.Value, StringComparer.Ordinal, isEarlyValidation: true); // Validate early if possible
env = stepProperty.Value;
break;
case WorkflowTemplateConstants.Id:
id = stepProperty.Value.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Id}");
if (!String.IsNullOrEmpty(id.Value) &&
!idBuilder.TryAddKnownId(id.Value, out var error))
{
context.Error(id, error);
}
break;
case WorkflowTemplateConstants.If:
ifToken = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.If}");
break;
case WorkflowTemplateConstants.Name:
name = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Name}");
break;
case WorkflowTemplateConstants.Run:
run = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Run}");
break;
case WorkflowTemplateConstants.Shell:
shell = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Shell}");
break;
case WorkflowTemplateConstants.TimeoutMinutes:
ConvertToStepTimeout(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible
timeoutMinutes = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.TimeoutMinutes}");
break;
case WorkflowTemplateConstants.Uses:
uses = stepProperty.Value.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Uses}");
break;
case WorkflowTemplateConstants.With:
ConvertToStepInputs(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible
with = stepProperty.Value;
break;
case WorkflowTemplateConstants.WorkingDirectory:
workingDir = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.WorkingDirectory}");
break;
default:
propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Steps} item key"); // throws
break;
}
}
// Fixup the if-condition
ifCondition = ConvertToIfCondition(context, ifToken, IfKind.Step);
if (run != null)
{
return new RunStep
{
Id = id?.Value,
ContinueOnError = continueOnError,
Name = name,
If = ifCondition,
TimeoutMinutes = timeoutMinutes,
Env = env,
WorkingDirectory = workingDir,
Shell = shell,
Run = run,
};
}
else
{
uses.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Uses}");
var result = new ActionStep
{
Id = id?.Value,
ContinueOnError = continueOnError,
Name = name,
If = ifCondition,
TimeoutMinutes = timeoutMinutes,
Env = env,
Uses = uses,
With = with,
};
if (!uses.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal) &&
!uses.Value.StartsWith("./") &&
!uses.Value.StartsWith(".\\"))
{
var usesSegments = uses.Value.Split('@');
var pathSegments = usesSegments[0].Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
var gitRef = usesSegments.Length == 2 ? usesSegments[1] : String.Empty;
if (usesSegments.Length != 2 ||
pathSegments.Length < 2 ||
String.IsNullOrEmpty(pathSegments[0]) ||
String.IsNullOrEmpty(pathSegments[1]) ||
String.IsNullOrEmpty(gitRef))
{
context.Error(uses, $"Expected format {{org}}/{{repo}}[/path]@ref. Actual '{uses.Value}'");
}
}
return result;
}
}
///
/// When empty, default to "success()".
/// When a status function is not referenced, format as "success() && <CONDITION>".
///
private static BasicExpressionToken ConvertToIfCondition(
TemplateContext context,
TemplateToken token,
IfKind ifKind)
{
String condition;
if (token is null)
{
condition = null;
}
else if (token is BasicExpressionToken expressionToken)
{
condition = expressionToken.Expression;
}
else
{
var stringToken = token.AssertString($"{ifKind} {WorkflowTemplateConstants.If}");
condition = stringToken.Value;
}
if (String.IsNullOrWhiteSpace(condition))
{
return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, $"{WorkflowTemplateConstants.Success}()");
}
var expressionParser = new ExpressionParser();
var functions = default(IFunctionInfo[]);
var namedValues = default(INamedValueInfo[]);
switch (ifKind)
{
case IfKind.Job:
namedValues = s_jobIfNamedValues;
functions = s_jobConditionFunctions;
break;
case IfKind.Step:
namedValues = s_stepNamedValues;
functions = s_stepConditionFunctions;
break;
case IfKind.Snapshot:
namedValues = s_snapshotIfNamedValues;
functions = s_snapshotConditionFunctions;
break;
default:
throw new ArgumentException($"Unexpected IfKind Enum value '{ifKind}' encountered while translating the token '{token}' to an IfCondition.");
}
var node = default(ExpressionNode);
try
{
node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode;
}
catch (Exception ex)
{
context.Error(token, ex);
return null;
}
if (node == null)
{
return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, $"{WorkflowTemplateConstants.Success}()");
}
var hasStatusFunction = node.Traverse().Any(x =>
{
if (x is Function function)
{
return String.Equals(function.Name, WorkflowTemplateConstants.Always, StringComparison.OrdinalIgnoreCase) ||
String.Equals(function.Name, WorkflowTemplateConstants.Cancelled, StringComparison.OrdinalIgnoreCase) ||
String.Equals(function.Name, WorkflowTemplateConstants.Failure, StringComparison.OrdinalIgnoreCase) ||
String.Equals(function.Name, WorkflowTemplateConstants.Success, StringComparison.OrdinalIgnoreCase);
}
return false;
});
var finalCondition = hasStatusFunction ? condition : $"{WorkflowTemplateConstants.Success}() && ({condition})";
return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, finalCondition);
}
private static Permissions ConvertToPermissions(TemplateContext context, TemplateToken token)
{
if (token is StringToken)
{
var permissionLevel = PermissionLevel.NoAccess;
var permissionsStr = token.AssertString("permissions");
switch (permissionsStr.Value)
{
case "read-all":
permissionLevel = PermissionLevel.Read;
break;
case "write-all":
permissionLevel = PermissionLevel.Write;
break;
default:
permissionsStr.AssertUnexpectedValue(permissionsStr.Value);
break;
}
return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission);
}
var mapping = token.AssertMapping("permissions");
var permissions = new Permissions();
foreach (var pair in mapping)
{
var key = pair.Key.AssertString("permissions.key");
var permissionLevel = ConvertToPermissionLevel(context, pair.Value);
switch (key.Value)
{
case "actions":
permissions.Actions = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "attestations":
permissions.Attestations = permissionLevel;
break;
case "checks":
permissions.Checks = permissionLevel;
break;
case "contents":
permissions.Contents = permissionLevel;
break;
case "deployments":
permissions.Deployments = permissionLevel;
break;
case "issues":
permissions.Issues = permissionLevel;
break;
case "discussions":
permissions.Discussions = permissionLevel;
break;
case "packages":
permissions.Packages = permissionLevel;
break;
case "pages":
permissions.Pages = permissionLevel;
break;
case "pull-requests":
permissions.PullRequests = permissionLevel;
break;
case "repository-projects":
permissions.RepositoryProjects = permissionLevel;
break;
case "statuses":
permissions.Statuses = permissionLevel;
break;
case "security-events":
permissions.SecurityEvents = permissionLevel;
break;
case "id-token":
if (context.GetFeatures().IdToken)
{
permissions.IdToken = permissionLevel;
}
else
{
context.Error(key, $"The key 'id-token' is not allowed");
}
break;
case "models":
if (context.GetFeatures().AllowModelsPermission)
{
if (permissionLevel == PermissionLevel.Write)
{
permissions.Models = PermissionLevel.Read;
}
else
{
permissions.Models = permissionLevel;
}
}
else
{
context.Error(key, $"The permission 'models' is not allowed");
}
break;
default:
break;
}
}
return permissions;
}
private static PermissionLevel ConvertToPermissionLevel(
TemplateContext context,
TemplateToken token)
{
var level = token.AssertString("permissions.value");
switch (level.Value)
{
case "none":
return PermissionLevel.NoAccess;
case "read":
return PermissionLevel.Read;
case "write":
return PermissionLevel.Write;
default:
level.AssertUnexpectedValue(level.Value);
return PermissionLevel.NoAccess;
}
}
private static void ValidateWorkflowJobTrigger(
TemplateContext context,
ReusableWorkflowJob workflowJob)
{
ConvertToWorkflowJobInputs(context, workflowJob.InputDefinitions, workflowJob.InputValues, workflowJob, isEarlyValidation: true);
ValidateWorkflowJobSecrets(context, workflowJob.SecretDefinitions, workflowJob.SecretValues, workflowJob);
}
private static ExpressionData ConvertToInputValueDefinedType(
TemplateContext context,
string key,
StringToken definedType,
TemplateToken token,
Boolean isEarlyValidation = false)
{
var inputType = default(string);
switch (definedType.Value)
{
case WorkflowTemplateConstants.TypeBoolean:
inputType = WorkflowTemplateConstants.BooleanNeedsContext;
break;
case WorkflowTemplateConstants.TypeNumber:
inputType = WorkflowTemplateConstants.NumberNeedsContext;
break;
case WorkflowTemplateConstants.TypeString:
inputType = WorkflowTemplateConstants.StringNeedsContext;
break;
default:
// The schema for worflow_call.inputs only allows boolean, string, or number.
// We should have failed earlier if we receive any other type.
throw new ArgumentException($"Unexpected defined type '{definedType.Value}' when converting input value for '{key}'");
}
// Leverage the templating library to coerce or error
//
// During early validation, we're not actually evaluating any expressions with this call.
// Any allowed contexts (i.e. "github"/"inputs") have not been added yet, so the TemplateEvaluator
// will not unravel any expressions.
//
// During runtime, the expressions have already been expanded.
var result = TemplateEvaluator.Evaluate(
context,
inputType,
token,
context.Memory.CalculateBytes(token), // Remove the size of the template token that is being replaced
token.FileId
);
if (isEarlyValidation && token.Traverse().Any(x => x is ExpressionToken))
{
return null;
}
return result.ToExpressionData();
}
internal static IDictionary ConvertToWorkflowJobOutputs(TemplateToken workflowJobOutputDefinitions)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
var outputs = workflowJobOutputDefinitions
.AssertMapping("workflow job output definitions")
.ToDictionary(
x => x.Key.AssertString("outputs key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase
);
foreach (var definition in outputs)
{
var spec = definition.Value.AssertMapping("workfow job output spec").ToDictionary(
x => x.Key.AssertString("outputs spec key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase
);
var value = spec["value"].AssertString("workfow job output value").Value;
result.Add(definition.Key, value);
}
return result;
}
internal static DictionaryExpressionData ConvertToWorkflowJobInputs(
TemplateContext context,
TemplateToken workflowJobInputDefinitions,
TemplateToken workflowJobInputValues,
ReusableWorkflowJob workflowJob,
Boolean isEarlyValidation = false)
{
var result = default(DictionaryExpressionData);
var inputDefinitions = workflowJobInputDefinitions?
.AssertMapping("workflow job input definitions")
.ToDictionary(
x => x.Key.AssertString("inputs key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase
);
var inputValues = workflowJobInputValues?
.AssertMapping("workflow job input values")
.ToDictionary(
x => x.Key.AssertString("with key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase
);
if (inputDefinitions != null)
{
result = new DictionaryExpressionData();
foreach (var definedItem in inputDefinitions)
{
string definedKey = definedItem.Key;
var definedInputSpec = definedItem.Value.AssertMapping($"input {definedKey}").ToDictionary(
x => x.Key.AssertString($"input {definedKey} key").Value,
x => x.Value,
StringComparer.OrdinalIgnoreCase);
var inputSpecType = definedInputSpec[WorkflowTemplateConstants.Type].AssertString($"inputs {definedKey} type"); // must exist, per schema
// if default provided, check with the defined type
if (definedInputSpec.TryGetValue(WorkflowTemplateConstants.Default, out TemplateToken defaultValue))
{
var value = ConvertToInputValueDefinedType(context, definedKey, inputSpecType, defaultValue, isEarlyValidation);
if (!isEarlyValidation)
{
result.Add(definedKey, value);
}
}
else if (!isEarlyValidation)
{
result.Add(definedKey, GetDefaultValueByType(inputSpecType).ToExpressionData());
}
// if input provided, check with defined type and continue
if (inputValues != null && inputValues.TryGetValue(definedKey, out TemplateToken inputValue))
{
var value = ConvertToInputValueDefinedType(context, definedKey, inputSpecType, inputValue, isEarlyValidation);
if (!isEarlyValidation)
{
result[definedKey] = value;
}
continue;
}
// if input required but not provided, error out
if (isEarlyValidation
&& definedInputSpec.TryGetValue(WorkflowTemplateConstants.Required, out TemplateToken requiredToken)
&& requiredToken.AssertBoolean(WorkflowTemplateConstants.Required).Value)
{
context.Error(workflowJob.Ref, $"Input {definedKey} is required, but not provided while calling.");
continue;
}
}
}
// Validating if any undefined inputs are provided
ValidateUndefinedParameters(context, inputDefinitions, inputValues, "input");
return result;
}
private static void ValidateWorkflowJobSecrets(
TemplateContext context,
MappingToken workflowJobSecretDefinitions,
MappingToken workflowJobSecretValues,
ReusableWorkflowJob workflowJob)
{
// if the secrets are inherited from the caller, we do not have any workflowJob.SecretValues (i.e. explicit mapping)
// Inherited org/repo/env secrets will be stored in context variables and will be validated there
if (workflowJob.InheritSecrets)
{
return;
}
var secretDefinitions = workflowJobSecretDefinitions?.ToDictionary(
x => x.Key.AssertString("secrets key").Value,
x => x.Value, StringComparer.OrdinalIgnoreCase);
var secretValues = workflowJobSecretValues?.ToDictionary(
x => x.Key.AssertString("secrets key").Value,
x => x.Value, StringComparer.OrdinalIgnoreCase);
if (secretDefinitions != null)
{
foreach (var definedItem in secretDefinitions)
{
if (definedItem.Value is NullToken nullToken)
{
continue;
}
var definedKey = definedItem.Key.ToString();
var definedSecretSpec = definedItem.Value.AssertMapping($"secret {definedKey}").ToDictionary(
x => x.Key.AssertString($"secret {definedKey} key").Value,
x => x.Value, StringComparer.OrdinalIgnoreCase);
// if secret provided, continue
if (secretValues != null && secretValues.TryGetValue(definedKey, out TemplateToken secretValue))
{
continue;
}
// if secret required but not provided, error out
if (definedSecretSpec.TryGetValue(WorkflowTemplateConstants.Required, out TemplateToken requiredToken)
&& requiredToken.AssertBoolean(WorkflowTemplateConstants.Required).Value)
{
context.Error(workflowJob.Ref, $"Secret {definedKey} is required, but not provided while calling.");
}
}
}
// Validating if any undefined secrets are provided
ValidateUndefinedParameters(context, secretDefinitions, secretValues, WorkflowTemplateConstants.Secret);
}
private static void ValidateUndefinedParameters(
TemplateContext context,
Dictionary definitions,
Dictionary providedValues,
string parameterType)
{
if (providedValues != null)
{
foreach (var providedValue in providedValues)
{
var providedKey = providedValue.Key;
if (definitions == null || !definitions.TryGetValue(providedKey, out TemplateToken value))
{
context.Error(providedValue.Value, $"Invalid {parameterType}, {providedKey} is not defined in the referenced workflow.");
}
}
}
}
private static TemplateToken GetDefaultValueByType(StringToken type)
{
return type.Value switch
{
WorkflowTemplateConstants.TypeString => new StringToken(type.FileId, type.Line, type.Column, string.Empty),
WorkflowTemplateConstants.TypeBoolean => new BooleanToken(type.FileId, type.Line, type.Column, false),
WorkflowTemplateConstants.TypeNumber => new NumberToken(type.FileId, type.Line, type.Column, 0.0),
_ => null,
};
}
private sealed class NodeInfo
{
public String Name { get; set; }
public List Needs { get; set; }
}
private enum IfKind
{
Job = 0,
Step = 1,
Snapshot = 2
}
private static readonly INamedValueInfo[] s_jobIfNamedValues = new INamedValueInfo[]
{
new NamedValueInfo(WorkflowTemplateConstants.GitHub),
new NamedValueInfo(WorkflowTemplateConstants.Vars),
new NamedValueInfo(WorkflowTemplateConstants.Inputs),
new NamedValueInfo(WorkflowTemplateConstants.Needs),
};
private static readonly INamedValueInfo[] s_stepNamedValues = new INamedValueInfo[]
{
new NamedValueInfo(WorkflowTemplateConstants.GitHub),
new NamedValueInfo(WorkflowTemplateConstants.Vars),
new NamedValueInfo(WorkflowTemplateConstants.Inputs),
new NamedValueInfo(WorkflowTemplateConstants.Needs),
new NamedValueInfo(WorkflowTemplateConstants.Strategy),
new NamedValueInfo(WorkflowTemplateConstants.Matrix),
new NamedValueInfo(WorkflowTemplateConstants.Steps),
new NamedValueInfo(WorkflowTemplateConstants.Job),
new NamedValueInfo(WorkflowTemplateConstants.Runner),
new NamedValueInfo(WorkflowTemplateConstants.Env),
};
private static readonly INamedValueInfo[] s_snapshotIfNamedValues = new INamedValueInfo[]
{
new NamedValueInfo(WorkflowTemplateConstants.GitHub),
new NamedValueInfo(WorkflowTemplateConstants.Vars),
new NamedValueInfo(WorkflowTemplateConstants.Inputs),
new NamedValueInfo(WorkflowTemplateConstants.Needs),
new NamedValueInfo(WorkflowTemplateConstants.Strategy),
new NamedValueInfo(WorkflowTemplateConstants.Matrix),
};
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
{
new FunctionInfo(WorkflowTemplateConstants.Always, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.Failure, 0, Int32.MaxValue),
new FunctionInfo(WorkflowTemplateConstants.Cancelled, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.Success, 0, Int32.MaxValue),
};
private static readonly IFunctionInfo[] s_stepConditionFunctions = new IFunctionInfo[]
{
new FunctionInfo(WorkflowTemplateConstants.Always, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.Cancelled, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.Failure, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.Success, 0, 0),
new FunctionInfo(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
};
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
}
}