mirror of
https://github.com/actions/runner.git
synced 2026-07-05 12:11:57 +08:00
Compare commits
2 Commits
philip-gai
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde968bf57 | ||
|
|
0e31cd5ff7 |
@@ -5,7 +5,7 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG RUNNER_VERSION
|
||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||
ARG DOCKER_VERSION=29.6.0
|
||||
ARG DOCKER_VERSION=29.6.1
|
||||
ARG BUILDX_VERSION=0.35.0
|
||||
|
||||
RUN apt update -y && apt install curl unzip -y
|
||||
|
||||
@@ -180,6 +180,7 @@ namespace GitHub.Runner.Common
|
||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
||||
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
|
||||
public static readonly string SelfRepository = "actions_self_repository";
|
||||
}
|
||||
|
||||
// Node version migration related constants
|
||||
|
||||
@@ -178,7 +178,7 @@ namespace GitHub.Runner.Worker
|
||||
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
|
||||
}
|
||||
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
@@ -186,6 +186,21 @@ namespace GitHub.Runner.Worker
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
// job.workflow_repository/workflow_sha point to the repo
|
||||
// containing the workflow file — correct for both regular
|
||||
// and reusable workflows. Always present when the server
|
||||
// supports $/. See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
@@ -301,16 +316,53 @@ namespace GitHub.Runner.Worker
|
||||
// then recurse per parent (which hits the cache, not the API).
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
var nextLevelRepoActions = nextLevel
|
||||
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.Select(x => x.action)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var group in nextLevel.GroupBy(x => x.parentId))
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
var groupActions = group.Select(x => x.action).ToList();
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
|
||||
// Self-repository path: group by parent so each group's
|
||||
// $/ refs resolve against the correct parent repo context.
|
||||
var groups = nextLevel.GroupBy(x => x.parentId).Select(group =>
|
||||
{
|
||||
string childRepoName = selfRepoName;
|
||||
string childRepoRef = selfRepoRef;
|
||||
var parentAction = repositoryActions.FirstOrDefault(a => a.Id == group.Key);
|
||||
if (parentAction?.Reference is Pipelines.RepositoryPathReference parentRef &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
return new { ParentId = group.Key, Actions = group.Select(x => x.action).ToList(), RepoName = childRepoName, RepoRef = childRepoRef };
|
||||
}).ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, group.Actions, group.RepoName, group.RepoRef);
|
||||
}
|
||||
|
||||
var nextLevelRepoActions = nextLevel
|
||||
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.Select(x => x.action)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, group.Actions, resolvedDownloadInfos, depth + 1, group.ParentId, group.RepoName, group.RepoRef);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Original path: no self-repository resolution needed.
|
||||
var nextLevelActions = nextLevel.Select(x => x.action).ToList();
|
||||
var nextLevelRepoActions = nextLevelActions
|
||||
.Where(x => x.Reference.Type == Pipelines.ActionSourceType.Repository)
|
||||
.ToList();
|
||||
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
|
||||
|
||||
foreach (var grp in nextLevel.GroupBy(x => x.parentId))
|
||||
{
|
||||
state = await PrepareActionsRecursiveAsync(executionContext, state, grp.Select(x => x.action).ToList(), resolvedDownloadInfos, depth + 1, grp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,13 +438,25 @@ namespace GitHub.Runner.Worker
|
||||
/// sub-actions individually, with no cross-depth deduplication.
|
||||
/// Used when the BatchActionResolution feature flag is disabled.
|
||||
/// </summary>
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
|
||||
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
if (depth > Constants.CompositeActionsMaxDepth)
|
||||
{
|
||||
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
|
||||
}
|
||||
|
||||
// Resolve self-repository ($/) references before processing
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selfRepoName))
|
||||
{
|
||||
selfRepoName = executionContext.JobContext?.WorkflowRepository;
|
||||
selfRepoRef = executionContext.JobContext?.WorkflowSha;
|
||||
}
|
||||
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
|
||||
}
|
||||
|
||||
var repositoryActions = new List<Pipelines.ActionStep>();
|
||||
|
||||
foreach (var action in actions)
|
||||
@@ -517,7 +581,17 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
|
||||
{
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
|
||||
// Propagate parent's repo context for nested self-repository resolution
|
||||
var parentRef = action.Reference as Pipelines.RepositoryPathReference;
|
||||
var childRepoName = selfRepoName;
|
||||
var childRepoRef = selfRepoRef;
|
||||
if (parentRef != null &&
|
||||
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
childRepoName = parentRef.Name;
|
||||
childRepoRef = parentRef.Ref;
|
||||
}
|
||||
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id, childRepoName, childRepoRef);
|
||||
}
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
|
||||
@@ -630,6 +704,12 @@ namespace GitHub.Runner.Worker
|
||||
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
|
||||
}
|
||||
}
|
||||
else if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Unresolved self-repository reference at load time — this
|
||||
// shouldn't happen but guard against NRE if it does.
|
||||
throw new InvalidOperationException($"Self-repository reference '$/{repoAction.Path}' was not resolved before LoadAction. Ensure the '{Constants.Runner.Features.SelfRepository}' feature flag is enabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
|
||||
@@ -765,6 +845,27 @@ namespace GitHub.Runner.Worker
|
||||
_cachedEmbeddedStepIds[action.Id].Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve self-repository refs in composite steps at load time.
|
||||
// During setup, resolution happens on a separate copy of these
|
||||
// step objects. At runtime, action.yml is re-parsed, producing
|
||||
// fresh self-repository refs that need resolution here.
|
||||
// When the parent is a dot-slash (self local-workspace) action,
|
||||
// repoAction.Name/Ref are null — fall back to workflow context.
|
||||
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
|
||||
{
|
||||
var parentName = repoAction.Name ?? executionContext.JobContext?.WorkflowRepository;
|
||||
var parentRef = repoAction.Ref ?? executionContext.JobContext?.WorkflowSha;
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.Steps, parentName, parentRef);
|
||||
if (compositeAction.PreSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PreSteps, parentName, parentRef);
|
||||
}
|
||||
if (compositeAction.PostSteps != null)
|
||||
{
|
||||
ResolveSelfRepositoryReferences(executionContext, compositeAction.PostSteps, parentName, parentRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1444,6 +1545,47 @@ namespace GitHub.Runner.Worker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves self-reference ($/) references by mutating them in-place
|
||||
/// to standard GitHub repository references with the containing repo's
|
||||
/// name and ref.
|
||||
/// </summary>
|
||||
private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable<Pipelines.ActionStep> actions, string repoName, string repoRef)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoName) || string.IsNullOrEmpty(repoRef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
|
||||
if (!string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Resolving self-repository reference reference '$/{repoAction.Path}' to '{repoName}/{repoAction.Path}@{repoRef}'");
|
||||
executionContext.Debug($"Resolving $/{repoAction.Path} → {repoName}/{repoAction.Path}@{repoRef}");
|
||||
|
||||
repoAction.RepositoryType = Pipelines.RepositoryTypes.GitHub;
|
||||
repoAction.Name = repoName;
|
||||
repoAction.Ref = repoRef;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If this is a reusable workflow job, ensure the workflow repo tarball
|
||||
/// is downloaded so self.workspace resolves to a real path on disk.
|
||||
/// Always downloads for reusable workflows when the feature flag is on,
|
||||
/// since step expressions are already expanded by the server and can't
|
||||
/// be scanned for self.* usage.
|
||||
/// </summary>
|
||||
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
|
||||
{
|
||||
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
|
||||
@@ -1459,6 +1601,11 @@ namespace GitHub.Runner.Worker
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.");
|
||||
}
|
||||
|
||||
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new NotSupportedException(repositoryReference.RepositoryType);
|
||||
|
||||
@@ -239,11 +239,6 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
Environment["ACTIONS_RESULTS_URL"] = resultsUrl;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_MODE"] = cacheMode;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false)
|
||||
{
|
||||
if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId))
|
||||
|
||||
@@ -78,11 +78,6 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
Environment["ACTIONS_CACHE_SERVICE_V2"] = bool.TrueString;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.TryGetValue("actions_cache_mode", out var cacheMode) && !string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_MODE"] = cacheMode;
|
||||
}
|
||||
|
||||
if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SetOrchestrationIdEnvForActions) ?? false)
|
||||
{
|
||||
if (ExecutionContext.Global.Variables.TryGetValue(Constants.Variables.System.OrchestrationId, out var orchestrationId) && !string.IsNullOrEmpty(orchestrationId))
|
||||
|
||||
@@ -171,12 +171,6 @@ namespace GitHub.Runner.Worker
|
||||
context.Output($"Secret source: {secretSource}");
|
||||
}
|
||||
|
||||
var cacheMode = jobContext.Global.Variables.Get("actions_cache_mode");
|
||||
if (!string.IsNullOrEmpty(cacheMode))
|
||||
{
|
||||
context.Output($"Actions cache-mode: {cacheMode}");
|
||||
}
|
||||
|
||||
var repoFullName = context.GetGitHubContext("repository");
|
||||
ArgUtil.NotNull(repoFullName, nameof(repoFullName));
|
||||
context.Debug($"Primary repository: {repoFullName}");
|
||||
|
||||
@@ -55,7 +55,18 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
break;
|
||||
case ActionSourceType.Repository:
|
||||
var repositoryReference = step.Reference as RepositoryPathReference;
|
||||
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
|
||||
if (!String.IsNullOrEmpty(repositoryReference.Name))
|
||||
{
|
||||
name = repositoryReference.Name;
|
||||
}
|
||||
else if (String.Equals(repositoryReference.RepositoryType, PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = PipelineConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
name = PipelineConstants.SelfAlias;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -600,6 +611,14 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
|
||||
Path = uses.Value
|
||||
};
|
||||
}
|
||||
else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath))
|
||||
{
|
||||
result.Reference = new RepositoryPathReference
|
||||
{
|
||||
RepositoryType = PipelineConstants.SelfRepositoryAlias,
|
||||
Path = selfPath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = uses.Value.Split('@');
|
||||
|
||||
@@ -38,10 +38,43 @@ namespace GitHub.DistributedTask.Pipelines
|
||||
public static readonly Int32 MaxNodeNameLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for the self repository.
|
||||
/// Alias for the self local-workspace repository type (./ syntax).
|
||||
/// Resolves to the local checkout on the runner.
|
||||
/// </summary>
|
||||
public static readonly String SelfAlias = "self";
|
||||
|
||||
/// <summary>
|
||||
/// RepositoryType for self-repository references ($/ syntax).
|
||||
/// Resolves to "this repo, at this SHA" based on the containing YAML file.
|
||||
/// </summary>
|
||||
public static readonly String SelfRepositoryAlias = "selfRepository";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix for self-repository references in uses: values.
|
||||
/// </summary>
|
||||
public const String SelfRepositoryPrefix = "$/";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the uses value is a self-repository reference (starts with $/),
|
||||
/// and outputs the subpath after the prefix.
|
||||
/// </summary>
|
||||
public static bool TryParseSelfRepository(string usesValue, out string path)
|
||||
{
|
||||
if (usesValue != null && usesValue.StartsWith(SelfRepositoryPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
path = usesValue.Substring(SelfRepositoryPrefix.Length).TrimStart('/');
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
path = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error code during graph validation.
|
||||
/// </summary>
|
||||
|
||||
@@ -1605,6 +1605,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
{
|
||||
id = WorkflowConstants.SelfAlias;
|
||||
}
|
||||
else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
|
||||
{
|
||||
id = WorkflowConstants.SelfRepositoryAlias;
|
||||
}
|
||||
else
|
||||
{
|
||||
var usesSegments = action.Uses!.Value.Split('@');
|
||||
|
||||
@@ -26,10 +26,15 @@ namespace GitHub.Actions.WorkflowParser
|
||||
internal const Int32 MaxNodeNameLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for the self repository.
|
||||
/// Alias for the self local-workspace repository type (./ syntax).
|
||||
/// </summary>
|
||||
internal const String SelfAlias = "self";
|
||||
|
||||
/// <summary>
|
||||
/// RepositoryType for self-repository references ($/ syntax).
|
||||
/// </summary>
|
||||
internal const String SelfRepositoryAlias = "selfRepository";
|
||||
|
||||
public static class PermissionsPolicy
|
||||
{
|
||||
public const string LimitedRead = "LimitedRead";
|
||||
|
||||
@@ -585,9 +585,9 @@ runs:
|
||||
|
||||
//Assert
|
||||
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
|
||||
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
|
||||
var di = new DirectoryInfo(destDirectory);
|
||||
Assert.NotNull(di.LinkTarget);
|
||||
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
|
||||
var di = new DirectoryInfo(destDirectory);
|
||||
Assert.NotNull(di.LinkTarget);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -2441,7 +2441,7 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LoadsNode24ActionDefinition()
|
||||
@@ -2509,7 +2509,7 @@ runs:
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
@@ -3533,5 +3533,604 @@ runs:
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero()
|
||||
{
|
||||
// Self-references are only supported via run service (batch resolution path)
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string archiveFile = await CreateRepoArchive();
|
||||
using var stream = File.OpenRead(archiveFile);
|
||||
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
|
||||
var mockClientHandler = new Mock<HttpClientHandler>();
|
||||
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
|
||||
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
|
||||
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
|
||||
_hc.SetSingleton(mockHandlerFactory.Object);
|
||||
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
|
||||
// Act — resolution mutates the reference in-place before download/prepare.
|
||||
// The archive doesn't contain the subpath, so prepare will fail, but the
|
||||
// reference is already resolved by that point.
|
||||
try
|
||||
{
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
|
||||
{
|
||||
// Expected: test archive lacks the action.yml at the resolved subpath
|
||||
}
|
||||
|
||||
// Assert — the reference should be resolved to a GitHub repo reference
|
||||
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
|
||||
Assert.Equal(RepoName, repoRef.Name);
|
||||
Assert.Equal(RepoSha, repoRef.Ref);
|
||||
Assert.Equal("actions/my-action", repoRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_NotResolvedWhenFeatureFlagDisabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/my-repo");
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns("abc123");
|
||||
// Feature flag NOT set
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act & Assert — should throw because unresolved self-reference hits GetDownloadInfoLookupKey
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite()
|
||||
{
|
||||
// Composite action at $/actions/parent uses $/actions/child (same repo).
|
||||
// This tests the batch path fix: $/ refs in nextLevel must be resolved
|
||||
// BEFORE ResolveNewActionsAsync, otherwise GetDownloadInfoLookupKey throws.
|
||||
// We pre-stage only the parent action.yml on disk so the composite steps
|
||||
// are discovered, but we DON'T stage the child — a download failure for
|
||||
// the child is fine; the important thing is that $/ was resolved
|
||||
// (no InvalidOperationException from GetDownloadInfoLookupKey).
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage parent action on disk as a composite that uses $/actions/child.
|
||||
// We use rootStepId != default to avoid directory deletion,
|
||||
// and create the watermark + action.yml in the expected location.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Parent'
|
||||
description: 'Composite parent'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/actions/child
|
||||
");
|
||||
// Stage child action too (as a leaf node action)
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Node child'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
// Write watermark
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/parent"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/ and not throw InvalidOperationException
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level $/ resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("actions/parent", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_CrossRepoCompositeResolvesToParentRepo()
|
||||
{
|
||||
// External composite (external/foo@v1) uses $/lib/bar.
|
||||
// $/lib/bar should resolve to external/foo@v1 (the parent's repo),
|
||||
// NOT to the workflow's root repo.
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RootRepoName = "my-org/my-repo";
|
||||
const string RootRepoSha = "root-sha-111";
|
||||
const string ExtRepoName = "external/foo";
|
||||
const string ExtRepoRef = "v1";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RootRepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RootRepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RootRepoName;
|
||||
jobContext.WorkflowSha = RootRepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, ExtRepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), ExtRepoRef);
|
||||
Directory.CreateDirectory(destDir);
|
||||
File.WriteAllText(Path.Combine(destDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'External Foo'
|
||||
description: 'External composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/lib/bar
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "lib", "bar"));
|
||||
File.WriteAllText(Path.Combine(destDir, "lib", "bar", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Bar'
|
||||
description: 'Node action in external repo'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = ExtRepoName,
|
||||
Ref = ExtRepoRef,
|
||||
RepositoryType = Pipelines.RepositoryTypes.GitHub
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/lib/bar to external/foo@v1/lib/bar
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — the top-level ref is unchanged (it was already concrete)
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(ExtRepoName, topRef.Name);
|
||||
Assert.Equal(ExtRepoRef, topRef.Ref);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_MultiLevelChain()
|
||||
{
|
||||
// $/a → composite → $/b → composite → $/c (three levels, same repo)
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "chain-sha-222";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "a"));
|
||||
File.WriteAllText(Path.Combine(destDir, "a", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'A'
|
||||
description: 'Level 0 composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/b
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "b"));
|
||||
File.WriteAllText(Path.Combine(destDir, "b", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'B'
|
||||
description: 'Level 1 composite'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/c
|
||||
");
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "c"));
|
||||
File.WriteAllText(Path.Combine(destDir, "c", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'C'
|
||||
description: 'Level 2 node leaf'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "a"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — three-level $/ chain should resolve without error
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level ref resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("a", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero_LegacyPath()
|
||||
{
|
||||
// Same as ResolvesAtDepthZero but on the legacy (non-batch) path
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
var actionId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = actionId,
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/my-action"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
string archiveFile = await CreateRepoArchive();
|
||||
using var stream = File.OpenRead(archiveFile);
|
||||
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
|
||||
var mockClientHandler = new Mock<HttpClientHandler>();
|
||||
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
|
||||
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
|
||||
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
|
||||
_hc.SetSingleton(mockHandlerFactory.Object);
|
||||
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
|
||||
// Act
|
||||
try
|
||||
{
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
|
||||
{
|
||||
// Expected: test archive lacks the action.yml at the resolved subpath
|
||||
}
|
||||
|
||||
// Assert — the reference should be resolved to a GitHub repo reference
|
||||
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
|
||||
Assert.Equal(RepoName, repoRef.Name);
|
||||
Assert.Equal(RepoSha, repoRef.Ref);
|
||||
Assert.Equal("actions/my-action", repoRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite_LegacyPath()
|
||||
{
|
||||
// Same as ResolvesNestedInComposite but on the legacy (non-batch) path.
|
||||
// Verifies that $/ resolution works when batch action resolution is disabled.
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string RepoName = "my-org/my-repo";
|
||||
const string RepoSha = "abc123def456";
|
||||
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
|
||||
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
|
||||
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = RepoName;
|
||||
jobContext.WorkflowSha = RepoSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage parent action on disk as a composite that uses $/actions/child.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Parent'
|
||||
description: 'Composite parent'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: $/actions/child
|
||||
");
|
||||
// Stage child action too (as a leaf node action)
|
||||
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
|
||||
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Node child'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
// Write watermark
|
||||
File.WriteAllText($"{destDir}.completed", string.Empty);
|
||||
|
||||
var rootStepId = Guid.NewGuid();
|
||||
var actions = new List<Pipelines.ActionStep>
|
||||
{
|
||||
new Pipelines.ActionStep()
|
||||
{
|
||||
Name = "action",
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
|
||||
Path = "actions/parent"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should resolve $/ and not throw InvalidOperationException
|
||||
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
|
||||
|
||||
// Assert — top-level $/ resolved
|
||||
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
|
||||
Assert.Equal(RepoName, topRef.Name);
|
||||
Assert.Equal(RepoSha, topRef.Ref);
|
||||
Assert.Equal("actions/parent", topRef.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void LoadAction_DotSlashCompositeWithNestedSelfRepository_ResolvesViaWorkflowContext()
|
||||
{
|
||||
// Regression test: when a dot-slash (./) composite action contains a
|
||||
// nested $/actions/child step, LoadAction re-parses the action.yml at
|
||||
// runtime and must resolve the $/ ref. The parent is repositoryType "self"
|
||||
// so its Name and Ref are null — resolution must fall back to
|
||||
// WorkflowRepository/WorkflowSha from the job context. Before the fix,
|
||||
// this path hit a NullReferenceException at repoAction.Name.Replace().
|
||||
try
|
||||
{
|
||||
// Arrange
|
||||
Setup();
|
||||
const string WorkflowRepo = "my-org/my-repo";
|
||||
const string WorkflowSha = "abc123def456";
|
||||
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
|
||||
var jobContext = new JobContext();
|
||||
jobContext.WorkflowRepository = WorkflowRepo;
|
||||
jobContext.WorkflowSha = WorkflowSha;
|
||||
_ec.Setup(x => x.JobContext).Returns(jobContext);
|
||||
|
||||
// Stage the dot-slash composite in the workspace directory.
|
||||
// It contains a nested $/actions/child step.
|
||||
string workspaceDir = Path.Combine(_workFolder, "actions", "actions");
|
||||
string compositeDir = Path.Combine(workspaceDir, "my-composite");
|
||||
Directory.CreateDirectory(compositeDir);
|
||||
File.WriteAllText(Path.Combine(compositeDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'DotSlash Parent'
|
||||
description: 'Composite loaded via ./ that nests a $/ ref'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- run: echo 'hello'
|
||||
shell: bash
|
||||
- uses: $/actions/child
|
||||
");
|
||||
|
||||
// Stage the child action in the actions cache under the workflow repo.
|
||||
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
|
||||
string childDir = Path.Combine(actionsDir, WorkflowRepo.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), WorkflowSha, "actions", "child");
|
||||
Directory.CreateDirectory(childDir);
|
||||
File.WriteAllText(Path.Combine(childDir, Constants.Path.ActionManifestYmlFile), @"
|
||||
name: 'Child'
|
||||
description: 'Leaf action'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'index.js'
|
||||
");
|
||||
|
||||
// Create dot-slash step with Name = null (the real scenario).
|
||||
var instance = new Pipelines.ActionStep()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Reference = new Pipelines.RepositoryPathReference()
|
||||
{
|
||||
Name = null,
|
||||
Ref = null,
|
||||
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
|
||||
Path = "my-composite"
|
||||
}
|
||||
};
|
||||
|
||||
// Act — should NOT throw NullReferenceException
|
||||
Definition definition = _actionManager.LoadAction(_ec.Object, instance);
|
||||
|
||||
// Assert — loaded the composite successfully
|
||||
Assert.NotNull(definition);
|
||||
Assert.NotNull(definition.Data);
|
||||
Assert.Equal(ActionExecutionType.Composite, definition.Data.Execution.ExecutionType);
|
||||
|
||||
// Assert — the nested $/ step was resolved to the workflow repo
|
||||
var compositeData = definition.Data.Execution as CompositeActionExecutionData;
|
||||
Assert.NotNull(compositeData);
|
||||
var childStep = compositeData.Steps
|
||||
.OfType<Pipelines.ActionStep>()
|
||||
.FirstOrDefault(s => s.Reference is Pipelines.RepositoryPathReference r
|
||||
&& r.Path == "actions/child");
|
||||
Assert.NotNull(childStep);
|
||||
var childRef = childStep.Reference as Pipelines.RepositoryPathReference;
|
||||
Assert.Equal(Pipelines.RepositoryTypes.GitHub, childRef.RepositoryType);
|
||||
Assert.Equal(WorkflowRepo, childRef.Name);
|
||||
Assert.Equal(WorkflowSha, childRef.Ref);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Teardown();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Actions.RunService.WebApi;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Runner.Worker.Handlers;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
@@ -94,260 +85,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal("ubuntu:20.04", _stepTelemetry.Action);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
[InlineData("write")]
|
||||
[InlineData("write-only")]
|
||||
public async Task RunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode)
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", mode }
|
||||
});
|
||||
|
||||
Assert.True(environment.TryGetValue("ACTIONS_CACHE_MODE", out var value));
|
||||
Assert.Equal(mode, value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotExportCacheModeEnv_WhenVariableEmpty()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", "" }
|
||||
});
|
||||
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_CacheModeCoexistsWithCacheServiceV2()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_uses_cache_service_v2", "true" },
|
||||
{ "actions_cache_mode", "read" }
|
||||
});
|
||||
|
||||
Assert.Equal(bool.TrueString, environment["ACTIONS_CACHE_SERVICE_V2"]);
|
||||
Assert.Equal("read", environment["ACTIONS_CACHE_MODE"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task RunAsync_DoesNotAffectRuntimeEnv_WhenCacheModeAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var environment = await RunNodeScriptActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
// Baseline runtime env is still exported and cache-mode adds nothing.
|
||||
Assert.Equal("https://pipelines.actions.githubusercontent.com/", environment["ACTIONS_RUNTIME_URL"]);
|
||||
Assert.Equal("token", environment["ACTIONS_RUNTIME_TOKEN"]);
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
Assert.False(environment.ContainsKey("ACTIONS_CACHE_SERVICE_V2"));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
public async Task ContainerRunAsync_ExportsCacheModeEnv_WhenVariableSet(string mode)
|
||||
{
|
||||
// Container actions only run on Linux; RunAsync throws on other platforms.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var container = await RunContainerActionHandlerAsync(hc, new Dictionary<string, VariableValue>
|
||||
{
|
||||
{ "actions_cache_mode", mode }
|
||||
});
|
||||
|
||||
Assert.True(container.ContainerEnvironmentVariables.TryGetValue("ACTIONS_CACHE_MODE", out var value));
|
||||
Assert.Equal(mode, value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ContainerRunAsync_DoesNotExportCacheModeEnv_WhenVariableAbsent()
|
||||
{
|
||||
// Container actions only run on Linux; RunAsync throws on other platforms.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var container = await RunContainerActionHandlerAsync(hc, new Dictionary<string, VariableValue>());
|
||||
|
||||
Assert.False(container.ContainerEnvironmentVariables.ContainsKey("ACTIONS_CACHE_MODE"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ContainerInfo> RunContainerActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
// Route through the container-hooks path so the handler skips docker build/run.
|
||||
variables[Constants.Runner.Features.AllowRunnerContainerHooks] = "true";
|
||||
Environment.SetEnvironmentVariable(Constants.Hooks.ContainerHooksPath, Path.Combine(hc.GetDirectory(WellKnownDirectory.Root), "hooks.js"));
|
||||
|
||||
var tempDirectory = hc.GetDirectory(WellKnownDirectory.Temp);
|
||||
Directory.CreateDirectory(Path.Combine(tempDirectory, "_runner_file_commands"));
|
||||
Directory.CreateDirectory(Path.Combine(tempDirectory, "_github_workflow"));
|
||||
var workspace = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), "workspace");
|
||||
Directory.CreateDirectory(workspace);
|
||||
|
||||
var serverVariables = new Variables(hc, variables);
|
||||
var endpoints = new List<ServiceEndpoint>
|
||||
{
|
||||
new ServiceEndpoint()
|
||||
{
|
||||
Name = WellKnownServiceEndpointNames.SystemVssConnection,
|
||||
Url = new Uri("https://pipelines.actions.githubusercontent.com"),
|
||||
Authorization = new EndpointAuthorization()
|
||||
{
|
||||
Scheme = "Test",
|
||||
Parameters = { { "AccessToken", "token" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
Endpoints = endpoints,
|
||||
PrependPath = new List<string>(),
|
||||
EnvironmentVariables = new Dictionary<string, string>()
|
||||
});
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
_ec.Setup(x => x.JobContext).Returns(new JobContext());
|
||||
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(workspace);
|
||||
|
||||
ContainerInfo captured = null;
|
||||
var hookManager = new Mock<IContainerHookManager>();
|
||||
hookManager.Setup(x => x.RunContainerStepAsync(It.IsAny<IExecutionContext>(), It.IsAny<ContainerInfo>(), It.IsAny<string>()))
|
||||
.Callback((IExecutionContext ec, ContainerInfo container, string dockerFile) => { captured = container; })
|
||||
.Returns(Task.CompletedTask);
|
||||
hc.SetSingleton(hookManager.Object);
|
||||
hc.SetSingleton(new Mock<IActionManifestManagerWrapper>().Object);
|
||||
|
||||
var handler = new ContainerActionHandler();
|
||||
handler.Initialize(hc);
|
||||
handler.ExecutionContext = _ec.Object;
|
||||
handler.Environment = new Dictionary<string, string>();
|
||||
handler.Inputs = new Dictionary<string, string>();
|
||||
handler.Action = new ContainerRegistryReference() { Image = "alpine:latest" };
|
||||
handler.Data = new ContainerActionExecutionData() { Image = "docker://alpine:latest" };
|
||||
|
||||
await handler.RunAsync(ActionRunStage.Main);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> RunNodeScriptActionHandlerAsync(TestHostContext hc, IDictionary<string, VariableValue> variables)
|
||||
{
|
||||
var actionDirectory = Path.Combine(hc.GetDirectory(WellKnownDirectory.Work), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(actionDirectory);
|
||||
var scriptFile = "main.js";
|
||||
File.WriteAllText(Path.Combine(actionDirectory, scriptFile), "// noop");
|
||||
|
||||
var serverVariables = new Variables(hc, variables);
|
||||
var endpoints = new List<ServiceEndpoint>
|
||||
{
|
||||
new ServiceEndpoint()
|
||||
{
|
||||
Name = WellKnownServiceEndpointNames.SystemVssConnection,
|
||||
Url = new Uri("https://pipelines.actions.githubusercontent.com"),
|
||||
Authorization = new EndpointAuthorization()
|
||||
{
|
||||
Scheme = "Test",
|
||||
Parameters = { { "AccessToken", "token" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_ec.Setup(x => x.Global).Returns(new GlobalContext()
|
||||
{
|
||||
Variables = serverVariables,
|
||||
Endpoints = endpoints,
|
||||
PrependPath = new List<string>(),
|
||||
EnvironmentVariables = new Dictionary<string, string>()
|
||||
});
|
||||
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
|
||||
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(actionDirectory);
|
||||
_ec.Setup(x => x.GetMatchers()).Returns(new List<IssueMatcherConfig>());
|
||||
_ec.Setup(x => x.ForceCompleted).Returns(new TaskCompletionSource<int>().Task);
|
||||
_ec.Setup(x => x.CancellationToken).Returns(CancellationToken.None);
|
||||
|
||||
var stepHost = new Mock<IStepHost>();
|
||||
stepHost.Setup(x => x.DetermineNodeRuntimeVersion(It.IsAny<IExecutionContext>(), It.IsAny<string>())).ReturnsAsync("node20");
|
||||
stepHost.Setup(x => x.ResolvePathForStepHost(It.IsAny<IExecutionContext>(), It.IsAny<string>())).Returns((IExecutionContext ec, string path) => path);
|
||||
stepHost.Setup(x => x.ExecuteAsync(
|
||||
It.IsAny<IExecutionContext>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, string>>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<System.Text.Encoding>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(0);
|
||||
|
||||
var handler = new NodeScriptActionHandler();
|
||||
handler.Initialize(hc);
|
||||
handler.ExecutionContext = _ec.Object;
|
||||
handler.StepHost = stepHost.Object;
|
||||
handler.Environment = new Dictionary<string, string>();
|
||||
handler.Inputs = new Dictionary<string, string>();
|
||||
handler.RuntimeVariables = serverVariables;
|
||||
handler.ActionDirectory = actionDirectory;
|
||||
handler.Action = new RepositoryPathReference() { Name = "actions/checkout", Ref = "v2" };
|
||||
handler.Data = new NodeJSActionExecutionData() { Script = scriptFile, NodeVersion = "node20" };
|
||||
|
||||
await handler.RunAsync(ActionRunStage.Main);
|
||||
|
||||
return handler.Environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,54 +238,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData("read")]
|
||||
[InlineData("none")]
|
||||
[InlineData("write")]
|
||||
[InlineData("write-only")]
|
||||
public async Task InitializeJob_LogsCacheMode_WhenVariableSet(string mode)
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
_jobEc.Global.Variables.Set("actions_cache_mode", mode);
|
||||
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
_jobServerQueue.Verify(
|
||||
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains($"Actions cache-mode: {mode}")), It.IsAny<long?>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task InitializeJob_DoesNotLogCacheMode_WhenVariableAbsent()
|
||||
{
|
||||
using (TestHostContext hc = CreateTestContext())
|
||||
{
|
||||
var jobExtension = new JobExtension();
|
||||
jobExtension.Initialize(hc);
|
||||
|
||||
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
|
||||
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
|
||||
|
||||
await jobExtension.InitializeJob(_jobEc, _message);
|
||||
|
||||
_jobServerQueue.Verify(
|
||||
x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(m => m.Contains("Actions cache-mode:")), It.IsAny<long?>()),
|
||||
Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
|
||||
Reference in New Issue
Block a user