diff --git a/docs/start/envlinux.md b/docs/start/envlinux.md
index 031902c2e15..86f1416118f 100644
--- a/docs/start/envlinux.md
+++ b/docs/start/envlinux.md
@@ -8,7 +8,7 @@ Please see "[Supported architectures and operating systems for self-hosted runne
## Install .Net Core 3.x Linux Dependencies
-The `./config.sh` will check .Net Core 3.x dependencies during runner configuration.
+The [config.sh](../../src/Misc/layoutroot/config.sh) will check .Net Core 3.x dependencies during runner configuration.
You might see something like this which indicate a dependency's missing.
```bash
./config.sh
@@ -17,7 +17,7 @@ You might see something like this which indicate a dependency's missing.
Dependencies is missing for Dotnet Core 6.0
Execute ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.
```
-You can easily correct the problem by executing `./bin/installdependencies.sh`.
+You can easily correct the problem by executing [installdependencies.sh](../../src/Misc/layoutbin/installdependencies.sh).
The `installdependencies.sh` script should install all required dependencies on all supported Linux versions
> Note: The `installdependencies.sh` script will try to use the default package management mechanism on your Linux flavor (ex. `yum`/`apt-get`/`apt`).
diff --git a/images/Dockerfile b/images/Dockerfile
index 713d4ffe1a2..426e3342b6d 100644
--- a/images/Dockerfile
+++ b/images/Dockerfile
@@ -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
diff --git a/patches/last_processed_commit.txt b/patches/last_processed_commit.txt
index e573e42dc10..0ade7f6ae6d 100644
--- a/patches/last_processed_commit.txt
+++ b/patches/last_processed_commit.txt
@@ -1 +1 @@
-c814d7ca46f403a860f84c4e54b78c5a2d17e045
+35e45850b519df66a669e2c91e0917804a33d0c7
diff --git a/patches/runner-main-sdk8-ppc64le.patch b/patches/runner-main-sdk8-ppc64le.patch
index a8bfc6b6c96..556c4fd42dc 100644
--- a/patches/runner-main-sdk8-ppc64le.patch
+++ b/patches/runner-main-sdk8-ppc64le.patch
@@ -16,7 +16,7 @@ index 9c069b12..d26b0dc2 100644
diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh
-index 46384bb4..fc2a9456 100755
+index 4947a9bd..8103a2db 100755
--- a/src/Misc/externals.sh
+++ b/src/Misc/externals.sh
@@ -187,3 +187,13 @@ fi
@@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755
if ! [ -x "$(command -v ldconfig)" ]; then
diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs
-index 8536e942..070a8004 100644
+index 66b32c24..6e46eaf4 100644
--- a/src/Runner.Common/Constants.cs
+++ b/src/Runner.Common/Constants.cs
@@ -59,7 +59,9 @@ namespace GitHub.Runner.Common
@@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644
NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051
diff --git a/src/dev.sh b/src/dev.sh
-index fafdbffb..baed6dd2 100755
+index 7d9aa58d..0b485e47 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then
@@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644
-# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045
+# From upstream commit: 35e45850b519df66a669e2c91e0917804a33d0c7
diff --git a/patches/runner-main-sdk8-s390x.patch b/patches/runner-main-sdk8-s390x.patch
index a8bfc6b6c96..556c4fd42dc 100644
--- a/patches/runner-main-sdk8-s390x.patch
+++ b/patches/runner-main-sdk8-s390x.patch
@@ -16,7 +16,7 @@ index 9c069b12..d26b0dc2 100644
diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh
-index 46384bb4..fc2a9456 100755
+index 4947a9bd..8103a2db 100755
--- a/src/Misc/externals.sh
+++ b/src/Misc/externals.sh
@@ -187,3 +187,13 @@ fi
@@ -86,7 +86,7 @@ index 14cc6bab..c8ed8b92 100755
if ! [ -x "$(command -v ldconfig)" ]; then
diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs
-index 8536e942..070a8004 100644
+index 66b32c24..6e46eaf4 100644
--- a/src/Runner.Common/Constants.cs
+++ b/src/Runner.Common/Constants.cs
@@ -59,7 +59,9 @@ namespace GitHub.Runner.Common
@@ -216,7 +216,7 @@ index a5a19aea..b2086aa5 100644
NU1701;NU1603;NU1603;xUnit2013;SYSLIB0050;SYSLIB0051
diff --git a/src/dev.sh b/src/dev.sh
-index fafdbffb..baed6dd2 100755
+index 7d9aa58d..0b485e47 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -54,6 +54,8 @@ elif [[ "$CURRENT_PLATFORM" == 'linux' ]]; then
@@ -284,4 +284,4 @@ index 056a312e..3f9a3679 100644
-# From upstream commit: c814d7ca46f403a860f84c4e54b78c5a2d17e045
+# From upstream commit: 35e45850b519df66a669e2c91e0917804a33d0c7
diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh
index 46384bb4845..4947a9bd43c 100755
--- a/src/Misc/externals.sh
+++ b/src/Misc/externals.sh
@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
NODE20_VERSION="20.20.2"
-NODE24_VERSION="24.17.0"
+NODE24_VERSION="24.18.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args
diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs
index 8536e942a27..66b32c2446c 100644
--- a/src/Runner.Common/Constants.cs
+++ b/src/Runner.Common/Constants.cs
@@ -180,6 +180,8 @@ public static class Features
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 AllowArtifactsFile = "actions_runner_allow_artifacts_file";
+ public static readonly string SelfRepository = "actions_self_repository";
}
// Node version migration related constants
@@ -227,6 +229,12 @@ public static class NodeMigration
public static readonly string UnsupportedStopCommandTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`.";
public static readonly string UnsupportedSummarySize = "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of {0}k, got {1}k. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
public static readonly string SummaryUploadError = "$GITHUB_STEP_SUMMARY upload aborted, an error occurred when uploading the summary. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
+
+ // $GITHUB_ARTIFACTS file command
+ public static readonly string ArtifactsFileSizeExceeded = "$GITHUB_ARTIFACTS file exceeds the maximum size of {0} KiB (got {1} KiB).";
+ public static readonly string ArtifactsAggregateLimitExceeded = "The job has exceeded the maximum of {0} declared artifacts.";
+ public static readonly string ArtifactsInvalidLine = "Invalid $GITHUB_ARTIFACTS entry on line {0}: {1}";
+ public static readonly string ArtifactsConflictingDigest = "Conflicting digest for artifact '{0}': previously declared as '{1}', now declared as '{2}'.";
}
public static class RunnerEvent
diff --git a/src/Runner.Common/ExtensionManager.cs b/src/Runner.Common/ExtensionManager.cs
index 2b7810eca45..b0bc2fef01e 100644
--- a/src/Runner.Common/ExtensionManager.cs
+++ b/src/Runner.Common/ExtensionManager.cs
@@ -62,6 +62,8 @@ private List LoadExtensions() where T : class, IExtension
Add(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker");
Add(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker");
Add(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker");
+ Add(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker");
+ Add(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker");
break;
case "GitHub.Runner.Listener.Check.ICheckExtension":
Add(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener");
diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs
index dea9e7dc032..d9e590cae37 100644
--- a/src/Runner.Worker/ActionManager.cs
+++ b/src/Runner.Worker/ActionManager.cs
@@ -178,7 +178,7 @@ public sealed class ActionManager : RunnerService, IActionManager
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
}
- private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Dictionary resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
+ private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Dictionary 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 @@ public sealed class ActionManager : RunnerService, IActionManager
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();
foreach (var action in actions)
@@ -228,7 +243,30 @@ public sealed class ActionManager : RunnerService, IActionManager
{
throw new Exception($"Missing download info for {lookupKey}");
}
- await DownloadRepositoryActionAsync(executionContext, downloadInfo);
+
+ Exception downloadFailure = null;
+ try
+ {
+ await DownloadRepositoryActionAsync(executionContext, downloadInfo);
+ }
+ catch (Exception ex)
+ {
+ // record the exception for telemetry, and rethrow the original exception to fail the step.
+ downloadFailure = ex;
+ throw;
+ }
+ finally
+ {
+ executionContext.Global.JobTelemetry.Add(new JobTelemetry()
+ {
+ Type = JobTelemetryType.General,
+ Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
+ {
+ Operation = "download_action",
+ Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name
+ }, Newtonsoft.Json.Formatting.None)}"
+ });
+ }
}
// Parse action.yml and collect composite sub-actions for batched
@@ -278,16 +316,53 @@ public sealed class ActionManager : RunnerService, IActionManager
// 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);
+ if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
+ {
+ // 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 nextLevel.GroupBy(x => x.parentId))
+ 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
{
- var groupActions = group.Select(x => x.action).ToList();
- state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
+ // 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);
+ }
}
}
@@ -363,13 +438,25 @@ public sealed class ActionManager : RunnerService, IActionManager
/// sub-actions individually, with no cross-depth deduplication.
/// Used when the BatchActionResolution feature flag is disabled.
///
- private async Task PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Int32 depth = 0, Guid parentStepId = default(Guid))
+ private async Task PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable 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();
foreach (var action in actions)
@@ -398,7 +485,30 @@ public sealed class ActionManager : RunnerService, IActionManager
if (repositoryActions.Count > 0)
{
// Get the download info
- var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
+ IDictionary downloadInfos = null;
+ Exception resolveFailure = null;
+ try
+ {
+ downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
+ }
+ catch (Exception ex)
+ {
+ // record the exception for telemetry, and rethrow the original exception to fail the step.
+ resolveFailure = ex;
+ throw;
+ }
+ finally
+ {
+ executionContext.Global.JobTelemetry.Add(new JobTelemetry()
+ {
+ Type = JobTelemetryType.General,
+ Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
+ {
+ Operation = "resolve_actions",
+ Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name
+ }, Newtonsoft.Json.Formatting.None)}"
+ });
+ }
// Download each action
foreach (var action in repositoryActions)
@@ -414,7 +524,29 @@ public sealed class ActionManager : RunnerService, IActionManager
throw new Exception($"Missing download info for {lookupKey}");
}
- await DownloadRepositoryActionAsync(executionContext, downloadInfo);
+ Exception downloadFailure = null;
+ try
+ {
+ await DownloadRepositoryActionAsync(executionContext, downloadInfo);
+ }
+ catch (Exception ex)
+ {
+ // record the exception for telemetry, and rethrow the original exception to fail the step.
+ downloadFailure = ex;
+ throw;
+ }
+ finally
+ {
+ executionContext.Global.JobTelemetry.Add(new JobTelemetry()
+ {
+ Type = JobTelemetryType.General,
+ Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
+ {
+ Operation = "download_action",
+ Result = downloadFailure == null ? "succeeded" : downloadFailure.GetType().Name
+ }, Newtonsoft.Json.Formatting.None)}"
+ });
+ }
}
// More preparation based on content in the repository (action.yml)
@@ -449,7 +581,17 @@ public sealed class ActionManager : RunnerService, IActionManager
}
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)
@@ -562,6 +704,12 @@ public Definition LoadAction(IExecutionContext executionContext, Pipelines.Actio
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);
@@ -697,6 +845,27 @@ public Definition LoadAction(IExecutionContext executionContext, Pipelines.Actio
_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
{
@@ -980,10 +1149,33 @@ private async Task ResolveNewActionsAsync(IExecutionContext executionContext, Li
if (actionsToResolve.Count > 0)
{
- var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
- foreach (var kvp in downloadInfos)
+ IDictionary downloadInfos = null;
+ Exception resolveFailure = null;
+ try
{
- resolvedDownloadInfos[kvp.Key] = kvp.Value;
+ downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
+ foreach (var kvp in downloadInfos)
+ {
+ resolvedDownloadInfos[kvp.Key] = kvp.Value;
+ }
+ }
+ catch (Exception ex)
+ {
+ // record the exception for telemetry, and rethrow the original exception to fail the step.
+ resolveFailure = ex;
+ throw;
+ }
+ finally
+ {
+ executionContext.Global.JobTelemetry.Add(new JobTelemetry()
+ {
+ Type = JobTelemetryType.General,
+ Message = $"resolve_download_actions_telemetry:{StringUtil.ConvertToJson(new ActionTelemetryPayload
+ {
+ Operation = "resolve_actions",
+ Result = resolveFailure == null ? "succeeded" : resolveFailure.GetType().Name
+ }, Newtonsoft.Json.Formatting.None)}"
+ });
}
}
}
@@ -1209,6 +1401,12 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont
private string GetWatermarkFilePath(string directory) => directory + ".completed";
+ private sealed class ActionTelemetryPayload
+ {
+ public string Operation { get; set; }
+ public string Result { get; set; }
+ }
+
private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
{
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
@@ -1347,6 +1545,47 @@ private ActionSetupInfo PrepareRepositoryActionAsync(IExecutionContext execution
}
}
+ ///
+ /// Resolves self-reference ($/) references by mutating them in-place
+ /// to standard GitHub repository references with the containing repo's
+ /// name and ref.
+ ///
+ private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
{
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
@@ -1362,6 +1601,11 @@ private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
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);
diff --git a/src/Runner.Worker/ArtifactSubject.cs b/src/Runner.Worker/ArtifactSubject.cs
new file mode 100644
index 00000000000..fb90c896646
--- /dev/null
+++ b/src/Runner.Worker/ArtifactSubject.cs
@@ -0,0 +1,52 @@
+using System;
+
+namespace GitHub.Runner.Worker
+{
+ public enum ArtifactSubjectKind
+ {
+ File,
+ OciSubject,
+ }
+
+ ///
+ /// Represents a single artifact subject declared via the
+ /// GITHUB_ARTIFACTS per-step environment file.
+ ///
+ public sealed class ArtifactSubject : IEquatable
+ {
+ public ArtifactSubject(string name, string digest, ArtifactSubjectKind kind)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new ArgumentException("Name must not be null or empty.", nameof(name));
+ }
+ if (string.IsNullOrEmpty(digest))
+ {
+ throw new ArgumentException("Digest must not be null or empty.", nameof(digest));
+ }
+ Name = name;
+ Digest = digest;
+ Kind = kind;
+ }
+
+ public string Name { get; }
+ public string Digest { get; }
+ public ArtifactSubjectKind Kind { get; }
+
+ public bool Equals(ArtifactSubject other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+ return string.Equals(Name, other.Name, StringComparison.Ordinal)
+ && string.Equals(Digest, other.Digest, StringComparison.Ordinal);
+ }
+
+ public override bool Equals(object obj) => Equals(obj as ArtifactSubject);
+
+ public override int GetHashCode() => HashCode.Combine(Name, Digest);
+
+ public override string ToString() => $"{Name}@{Digest}";
+ }
+}
diff --git a/src/Runner.Worker/ArtifactsListFileCommand.cs b/src/Runner.Worker/ArtifactsListFileCommand.cs
new file mode 100644
index 00000000000..aaad5695384
--- /dev/null
+++ b/src/Runner.Worker/ArtifactsListFileCommand.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using GitHub.Runner.Common;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker.Container;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// File command extension that exposes the job-scoped aggregate of
+ /// as a read-only JSON
+ /// file. Subsequent steps in the same job read the file via the
+ /// GITHUB_ARTIFACTS_LIST environment variable, getting a
+ /// running view of every artifact declared via
+ /// $GITHUB_ARTIFACTS in earlier steps.
+ ///
+ ///
+ /// The file uses the existing per-step file-command lifecycle:
+ /// creates a fresh
+ /// file, invokes here, exposes
+ /// the (translated) path to the step's environment, and (for the
+ /// read-only file) ignores anything the step writes back.
+ ///
+ /// The file is always written when the feature is enabled, so
+ /// consumers never need to branch on "did the runner inject this?".
+ /// An empty aggregate produces {"version":1,"subjects":[]}.
+ ///
+ public sealed class ArtifactsListFileCommand : RunnerService, IFileCommandExtension
+ {
+ public const int FormatVersion = 1;
+
+ public string ContextName => "artifacts_list";
+ public string FilePrefix => "artifacts_list_";
+
+ public Type ExtensionType => typeof(IFileCommandExtension);
+
+ public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
+ {
+ ArgUtil.NotNull(context, nameof(context));
+
+ // Feature flag gate. Mirrors CreateArtifactsFileCommand so the
+ // write side and the read side are toggled together.
+ var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
+ || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar));
+ if (!enabled)
+ {
+ Trace.Verbose("$GITHUB_ARTIFACTS_LIST publishing is disabled (feature flag and env-var fallback are both off).");
+ return;
+ }
+
+ var aggregate = context.Global.ArtifactSubjects
+ ?? new Dictionary(StringComparer.Ordinal);
+
+ var subjects = new JArray();
+ // Emit subjects sorted by name so the output is deterministic
+ // regardless of the backing dictionary's enumeration order
+ // (which is not contractually guaranteed).
+ foreach (var entry in aggregate.Values.OrderBy(v => v.Name, StringComparer.Ordinal))
+ {
+ subjects.Add(new JObject
+ {
+ ["name"] = entry.Name,
+ ["digest"] = entry.Digest,
+ ["kind"] = entry.Kind == ArtifactSubjectKind.OciSubject ? "oci" : "file",
+ });
+ }
+
+ var payload = new JObject
+ {
+ ["version"] = FormatVersion,
+ ["subjects"] = subjects,
+ };
+
+ // UTF-8 without BOM; consumers in other languages should not
+ // have to special-case a leading BOM.
+ File.WriteAllText(filePath, payload.ToString(Formatting.None), new UTF8Encoding(false));
+ Trace.Info($"Wrote $GITHUB_ARTIFACTS_LIST with {aggregate.Count} subject(s) to '{filePath}'");
+ }
+
+ public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
+ {
+ // Read-only file: anything the step writes here is ignored.
+ // The aggregate is fed only by the write-side $GITHUB_ARTIFACTS
+ // file processed by CreateArtifactsFileCommand.
+ }
+ }
+}
diff --git a/src/Runner.Worker/CreateArtifactsFileCommand.cs b/src/Runner.Worker/CreateArtifactsFileCommand.cs
new file mode 100644
index 00000000000..da0d694d64f
--- /dev/null
+++ b/src/Runner.Worker/CreateArtifactsFileCommand.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using GitHub.Runner.Common;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker.Container;
+
+namespace GitHub.Runner.Worker
+{
+ ///
+ /// File command extension that implements the GITHUB_ARTIFACTS
+ /// per-step environment file contract.
+ ///
+ ///
+ /// Lifecycle is identical to the other per-step file commands:
+ /// creates an empty file before each
+ /// step runs and invokes after the step
+ /// completes. This class is responsible for parsing the file's
+ /// contents, validating each entry, and aggregating the resulting
+ /// (name, digest) pairs onto
+ /// at job scope.
+ ///
+ /// The feature is gated by the actions_runner_allow_artifacts_file
+ /// feature flag. When the flag is disabled, the env var is still
+ /// exposed but writes are silently ignored.
+ ///
+ public sealed class CreateArtifactsFileCommand : RunnerService, IFileCommandExtension
+ {
+ // Each per-step file may contain at most 1 MiB.
+ public const int MaxFileSizeBytes = 1024 * 1024;
+
+ // A job may declare at most 500 artifacts in aggregate.
+ public const int MaxAggregateArtifacts = 500;
+
+ public string ContextName => "artifacts";
+ public string FilePrefix => "artifacts_";
+
+ // Runner-side environment variable that enables the feature on
+ // self-hosted runners where the server-side feature flag is not
+ // configurable. Mirrors patterns like
+ // ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER elsewhere in the runner.
+ public const string EnableEnvVar = "ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE";
+
+ public Type ExtensionType => typeof(IFileCommandExtension);
+
+ // Recognized scheme prefixes (case-insensitive).
+ private const string FileScheme = "file://";
+ private const string OciScheme = "oci://";
+
+ // Matches "://...". Used to detect unsupported URI schemes.
+ // Scheme grammar per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
+ private static readonly Regex s_schemeRegex = new(
+ @"^[A-Za-z][A-Za-z0-9+.\-]*://",
+ RegexOptions.Compiled);
+
+ // Matches "[@:" where is sha256/sha384/sha512.
+ // Hex length is validated separately so we can produce a precise error.
+ private static readonly Regex s_ociDigestSuffixRegex = new(
+ @"^(?][.+)@(?sha(?:256|384|512)):(?[0-9a-fA-F]+)$",
+ RegexOptions.Compiled);
+
+ public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
+ {
+ ArgUtil.NotNull(context, nameof(context));
+
+ // Feature flag gate. Enabled when either the server-side
+ // feature flag is set, or the runner is started with the
+ // ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE env var set to true
+ // (the env-var fallback exists so self-hosted runners can
+ // opt in locally). Silently no-op when disabled.
+ var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
+ || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(EnableEnvVar));
+ if (!enabled)
+ {
+ Trace.Verbose("$GITHUB_ARTIFACTS processing is disabled (feature flag and env-var fallback are both off).");
+ return;
+ }
+
+ Trace.Info($"Processing $GITHUB_ARTIFACTS file '{filePath}'");
+
+ if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
+ {
+ Trace.Info("$GITHUB_ARTIFACTS file does not exist; nothing to process.");
+ return;
+ }
+
+ var fileSize = new FileInfo(filePath).Length;
+ if (fileSize == 0)
+ {
+ Trace.Info("$GITHUB_ARTIFACTS file is empty; nothing to process.");
+ return;
+ }
+ if (fileSize > MaxFileSizeBytes)
+ {
+ throw new Exception(StringUtil.Format(
+ Constants.Runner.ArtifactsFileSizeExceeded,
+ MaxFileSizeBytes / 1024,
+ fileSize / 1024));
+ }
+
+ // Per-step subjects parsed from this file; aggregated into the
+ // job-level set at the end so a single malformed line fails the
+ // step without partially polluting the aggregate.
+ var parsed = new List<(int LineNumber, ArtifactSubject Subject)>();
+
+ // Relative artifact paths are resolved against the workspace
+ // root (GITHUB_WORKSPACE), not the step's working directory.
+ // This matches the established runner precedent set by
+ // hashFiles() which always resolve relative paths against
+ // the workspace root regardless of any step-level
+ // `working-directory:`.
+ var workspaceRoot = ResolveWorkspaceRoot(context);
+
+ var lines = File.ReadAllLines(filePath, Encoding.UTF8);
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var lineNumber = i + 1;
+ var raw = lines[i];
+ var trimmed = raw.Trim();
+ if (trimmed.Length == 0)
+ {
+ continue;
+ }
+ if (trimmed[0] == '#')
+ {
+ continue;
+ }
+
+ ArtifactSubject subject;
+ try
+ {
+ subject = ParseLine(trimmed, workspaceRoot, container);
+ }
+ catch (ArtifactsParseException ex)
+ {
+ throw new Exception(StringUtil.Format(
+ Constants.Runner.ArtifactsInvalidLine,
+ lineNumber,
+ ex.Message));
+ }
+
+ parsed.Add((lineNumber, subject));
+ }
+
+ // Aggregate at job scope: dedup identical, reject conflicts,
+ // enforce the 500-artifact cap (after dedup so identical
+ // duplicates above the cap do not fail).
+ var aggregate = context.Global.ArtifactSubjects;
+ if (aggregate == null)
+ {
+ throw new InvalidOperationException("Global.ArtifactSubjects is not initialized.");
+ }
+
+ var addedThisStep = 0;
+ foreach (var (lineNumber, subject) in parsed)
+ {
+ if (aggregate.TryGetValue(subject.Name, out var existing))
+ {
+ if (string.Equals(existing.Digest, subject.Digest, StringComparison.Ordinal))
+ {
+ // Identical declaration — silently deduplicate.
+ Trace.Info($"Skipped duplicate artifact subject '{subject.Name}' (digest={subject.Digest})");
+ continue;
+ }
+ throw new Exception(StringUtil.Format(
+ Constants.Runner.ArtifactsInvalidLine,
+ lineNumber,
+ StringUtil.Format(
+ Constants.Runner.ArtifactsConflictingDigest,
+ subject.Name,
+ existing.Digest,
+ subject.Digest)));
+ }
+
+ if (aggregate.Count >= MaxAggregateArtifacts)
+ {
+ throw new Exception(StringUtil.Format(
+ Constants.Runner.ArtifactsInvalidLine,
+ lineNumber,
+ StringUtil.Format(
+ Constants.Runner.ArtifactsAggregateLimitExceeded,
+ MaxAggregateArtifacts)));
+ }
+
+ aggregate[subject.Name] = subject;
+ addedThisStep++;
+ Trace.Info($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
+ context.Debug($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
+ }
+
+ if (addedThisStep > 0)
+ {
+ // Mirror the existing file-command UX: a single, terse
+ // user-visible line that confirms the declarations landed.
+ context.Output($"Captured {addedThisStep} artifact subject(s) from this step (job total: {aggregate.Count}).");
+ }
+ }
+
+ private ArtifactSubject ParseLine(string trimmed, string workspaceRoot, ContainerInfo container)
+ {
+ // Reject lines containing '=' — reserved for a future v2
+ // key/value extension to the format.
+ if (trimmed.IndexOf('=') >= 0)
+ {
+ throw new ArtifactsParseException("entries containing '=' are reserved and not permitted");
+ }
+
+ // Handle the explicit escape-hatch schemes first
+ // (case-insensitive).
+ if (StartsWithIgnoreCase(trimmed, FileScheme))
+ {
+ var path = trimmed.Substring(FileScheme.Length);
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArtifactsParseException("file:// entries must include a path");
+ }
+ return MakeFileSubject(path, workspaceRoot, container);
+ }
+ if (StartsWithIgnoreCase(trimmed, OciScheme))
+ {
+ var rest = trimmed.Substring(OciScheme.Length);
+ var match = s_ociDigestSuffixRegex.Match(rest);
+ if (!match.Success)
+ {
+ throw new ArtifactsParseException("oci:// entries must include an @sha{256,384,512}: digest");
+ }
+ return MakeOciSubject(match);
+ }
+
+ // Reject any other URI scheme up-front.
+ if (s_schemeRegex.IsMatch(trimmed))
+ {
+ throw new ArtifactsParseException("unsupported URI scheme");
+ }
+
+ // Otherwise discriminate syntactically: an entry that matches
+ // the OCI digest suffix shape (with the right hex length for
+ // its algorithm) is an OCI subject; everything else is a path.
+ var ociMatch = s_ociDigestSuffixRegex.Match(trimmed);
+ if (ociMatch.Success && IsExpectedHexLength(ociMatch.Groups["algo"].Value, ociMatch.Groups["hex"].Value))
+ {
+ return MakeOciSubject(ociMatch);
+ }
+
+ return MakeFileSubject(trimmed, workspaceRoot, container);
+ }
+
+ private static ArtifactSubject MakeOciSubject(Match match)
+ {
+ var refName = match.Groups["ref"].Value;
+ var algo = match.Groups["algo"].Value.ToLowerInvariant();
+ var hex = match.Groups["hex"].Value.ToLowerInvariant();
+
+ if (!IsExpectedHexLength(algo, hex))
+ {
+ throw new ArtifactsParseException(
+ $"digest '{algo}' must be {ExpectedHexLength(algo)} hex characters, got {hex.Length}");
+ }
+ if (string.IsNullOrEmpty(refName))
+ {
+ throw new ArtifactsParseException("oci subject must include a reference");
+ }
+
+ return new ArtifactSubject(refName, $"{algo}:{hex}", ArtifactSubjectKind.OciSubject);
+ }
+
+ private static ArtifactSubject MakeFileSubject(string declaredPath, string workspaceRoot, ContainerInfo container)
+ {
+ var hostPath = ResolveFilePath(declaredPath, workspaceRoot, container);
+
+ if (!File.Exists(hostPath))
+ {
+ if (Directory.Exists(hostPath))
+ {
+ throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
+ }
+ // For relative paths, surface where we looked so authors
+ // aren't surprised that resolution is workspace-relative.
+ if (!Path.IsPathRooted(declaredPath))
+ {
+ throw new ArtifactsParseException(
+ $"file '{declaredPath}' does not exist (relative paths are resolved against the workspace root '{workspaceRoot}')");
+ }
+ throw new ArtifactsParseException($"file '{declaredPath}' does not exist");
+ }
+
+ // FileInfo + File.GetAttributes guards against named pipes,
+ // device files, etc. We accept regular files and symlinks
+ // resolved to regular files.
+ var attrs = File.GetAttributes(hostPath);
+ if ((attrs & FileAttributes.Directory) == FileAttributes.Directory)
+ {
+ throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
+ }
+
+ string hex;
+ using (var stream = File.OpenRead(hostPath))
+ using (var sha = SHA256.Create())
+ {
+ var hash = sha.ComputeHash(stream);
+ var sb = new StringBuilder(hash.Length * 2);
+ foreach (var b in hash)
+ {
+ sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
+ }
+ hex = sb.ToString();
+ }
+
+ var name = Path.GetFileName(hostPath);
+ return new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.File);
+ }
+
+ private static string ResolveFilePath(string declaredPath, string workspaceRoot, ContainerInfo container)
+ {
+ if (Path.IsPathRooted(declaredPath))
+ {
+ if (container == null)
+ {
+ return declaredPath;
+ }
+
+ // Absolute path from a container step: it lives in the
+ // container's filesystem namespace, so translate it to the
+ // host path via the container's volume mounts.
+ // TranslateToHostPath returns the input unchanged when the
+ // path is not under any mount. We must NOT fall back to the
+ // host file at that same path -- that would hash an arbitrary
+ // host file the container step never referenced -- so reject
+ // it instead.
+ var hostPath = container.TranslateToHostPath(declaredPath);
+ if (string.Equals(hostPath, declaredPath, StringComparison.Ordinal))
+ {
+ throw new ArtifactsParseException(
+ $"absolute path '{declaredPath}' is not inside a volume mounted into the container and cannot be resolved");
+ }
+ return hostPath;
+ }
+
+ // Relative path: resolve against the workspace root
+ // (GITHUB_WORKSPACE).
+ var baseDir = workspaceRoot ?? string.Empty;
+ return Path.GetFullPath(Path.Combine(baseDir, declaredPath));
+ }
+
+ private static string ResolveWorkspaceRoot(IExecutionContext context)
+ {
+ // The workspace root (GITHUB_WORKSPACE) is the resolution base
+ // for all relative artifact paths.
+ var workspace = context.GetGitHubContext("workspace");
+ return string.IsNullOrEmpty(workspace) ? null : workspace;
+ }
+
+ private static bool StartsWithIgnoreCase(string s, string prefix)
+ {
+ return s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int ExpectedHexLength(string algo)
+ {
+ return algo.ToLowerInvariant() switch
+ {
+ "sha256" => 64,
+ "sha384" => 96,
+ "sha512" => 128,
+ _ => -1,
+ };
+ }
+
+ private static bool IsExpectedHexLength(string algo, string hex)
+ {
+ var expected = ExpectedHexLength(algo);
+ return expected > 0 && hex.Length == expected;
+ }
+
+ private sealed class ArtifactsParseException : Exception
+ {
+ public ArtifactsParseException(string message) : base(message) { }
+ }
+ }
+}
diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs
index 6d7698fdd9c..0f9410821c6 100644
--- a/src/Runner.Worker/ExecutionContext.cs
+++ b/src/Runner.Worker/ExecutionContext.cs
@@ -973,6 +973,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
Global.Arm32Node20Actions = new HashSet(StringComparer.OrdinalIgnoreCase);
+ // Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
+ Global.ArtifactSubjects = new Dictionary(StringComparer.Ordinal);
+
// Job Outputs
JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Runner.Worker/FileCommandManager.cs b/src/Runner.Worker/FileCommandManager.cs
index 7c3c0ef431a..9d8bbebb42b 100644
--- a/src/Runner.Worker/FileCommandManager.cs
+++ b/src/Runner.Worker/FileCommandManager.cs
@@ -55,6 +55,18 @@ public void InitializeFiles(IExecutionContext context, ContainerInfo container)
TryDeleteFile(newPath);
File.Create(newPath).Dispose();
+ // Give extensions a chance to populate the file before
+ // the step starts (e.g., read-only views of job state).
+ // Errors are logged but must not fail step setup.
+ try
+ {
+ fileCommand.PopulateInitialContents(context, newPath, container);
+ }
+ catch (Exception ex)
+ {
+ _trace.Warning($"Failed to populate initial contents for file command '{fileCommand.ContextName}': {ex}");
+ }
+
var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath;
context.SetGitHubContext(fileCommand.ContextName, pathToSet);
}
@@ -102,6 +114,14 @@ public interface IFileCommandExtension : IExtension
string FilePrefix { get; }
void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container);
+
+ // Optional hook invoked by FileCommandManager.InitializeFiles
+ // after creating the empty per-step file. Extensions that need to
+ // pre-populate the file (e.g., a read-only view of job-scoped
+ // state) override this; the default no-op preserves the existing
+ // "empty file at start of step" behavior for write-only file
+ // commands such as GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, etc.
+ void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) { }
}
public sealed class AddPathFileCommand : RunnerService, IFileCommandExtension
diff --git a/src/Runner.Worker/GitHubContext.cs b/src/Runner.Worker/GitHubContext.cs
index 710469ddbc1..3d01e4f7db5 100644
--- a/src/Runner.Worker/GitHubContext.cs
+++ b/src/Runner.Worker/GitHubContext.cs
@@ -15,6 +15,8 @@ public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextDa
"actor",
"actor_id",
"api_url",
+ "artifacts",
+ "artifacts_list",
"base_ref",
"env",
"event_name",
diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs
index 04abe003633..c2db20bd5ac 100644
--- a/src/Runner.Worker/GlobalContext.cs
+++ b/src/Runner.Worker/GlobalContext.cs
@@ -39,5 +39,9 @@ public sealed class GlobalContext
public HashSet UpgradedToNode24Actions { get; set; }
public HashSet Arm32Node20Actions { get; set; }
public IList ActionsDependencies { get; set; }
+
+ // Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
+ // Keyed by canonical subject name (OCI ref without digest, or file basename).
+ public IDictionary ArtifactSubjects { get; set; }
}
}
diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs
index 38176eb36c2..e8c5e91afe7 100644
--- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs
+++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs
@@ -55,7 +55,18 @@ public static List ConvertToSteps(
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 @@ private static ActionStep ConvertToStep(
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('@');
diff --git a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs
index 2e03671fbb2..b2928520cd7 100644
--- a/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs
+++ b/src/Sdk/DTPipelines/Pipelines/PipelineConstants.cs
@@ -38,10 +38,43 @@ public static class PipelineConstants
public static readonly Int32 MaxNodeNameLength = 100;
///
- /// Alias for the self repository.
+ /// Alias for the self local-workspace repository type (./ syntax).
+ /// Resolves to the local checkout on the runner.
///
public static readonly String SelfAlias = "self";
+ ///
+ /// RepositoryType for self-repository references ($/ syntax).
+ /// Resolves to "this repo, at this SHA" based on the containing YAML file.
+ ///
+ public static readonly String SelfRepositoryAlias = "selfRepository";
+
+ ///
+ /// The prefix for self-repository references in uses: values.
+ ///
+ public const String SelfRepositoryPrefix = "$/";
+
+ ///
+ /// Returns true if the uses value is a self-repository reference (starts with $/),
+ /// and outputs the subpath after the prefix.
+ ///
+ 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;
+ }
+
///
/// Error code during graph validation.
///
diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs
index df80774d3a6..7f344c4de63 100644
--- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs
+++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs
@@ -1605,6 +1605,10 @@ public static List ConvertToSteps(
{
id = WorkflowConstants.SelfAlias;
}
+ else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
+ {
+ id = WorkflowConstants.SelfRepositoryAlias;
+ }
else
{
var usesSegments = action.Uses!.Value.Split('@');
diff --git a/src/Sdk/WorkflowParser/WorkflowConstants.cs b/src/Sdk/WorkflowParser/WorkflowConstants.cs
index e35c244e65e..fc168981686 100644
--- a/src/Sdk/WorkflowParser/WorkflowConstants.cs
+++ b/src/Sdk/WorkflowParser/WorkflowConstants.cs
@@ -26,10 +26,15 @@ public static class WorkflowConstants
internal const Int32 MaxNodeNameLength = 100;
///
- /// Alias for the self repository.
+ /// Alias for the self local-workspace repository type (./ syntax).
///
internal const String SelfAlias = "self";
+ ///
+ /// RepositoryType for self-repository references ($/ syntax).
+ ///
+ internal const String SelfRepositoryAlias = "selfRepository";
+
public static class PermissionsPolicy
{
public const string LimitedRead = "LimitedRead";
diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs
index c612ac9d0fa..9b3138e5ba0 100644
--- a/src/Test/L0/Worker/ActionManagerL0.cs
+++ b/src/Test/L0/Worker/ActionManagerL0.cs
@@ -90,6 +90,11 @@ public async void PrepareActions_DownloadActionFromDotCom_OnPremises_Legacy()
var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "main", "action.yml");
Assert.True(File.Exists(actionYamlFile));
+
+ var telemetryMessages = GetTelemetryMessages();
+ Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions"));
+ Assert.True(ContainsTelemetry(telemetryMessages, "succeeded"));
+ Assert.True(ContainsTelemetry(telemetryMessages, "download_action"));
_hc.GetTrace().Info(File.ReadAllText(actionYamlFile));
}
finally
@@ -148,6 +153,11 @@ public async void PrepareActions_DownloadActionFromDotCom_ZipFileError()
// Act + Assert
await Assert.ThrowsAsync(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions));
+
+ var telemetryMessages = GetTelemetryMessages();
+ Assert.True(ContainsTelemetry(telemetryMessages, "resolve_actions"));
+ Assert.True(ContainsTelemetry(telemetryMessages, "download_action"));
+ Assert.True(ContainsTelemetry(telemetryMessages, "InvalidActionArchiveException"));
}
finally
{
@@ -215,6 +225,51 @@ public async void PrepareActions_DownloadUnknownActionFromGraph_OnPremises_Legac
}
}
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public async Task PrepareActions_ResolveActionDownloadInfo_RecordsTelemetry_OnFailure()
+ {
+ try
+ {
+ // Arrange
+ Setup();
+ _ec.Object.Global.Variables.Set(Constants.Variables.System.JobRequestType, "RunnerJobRequest");
+
+ _launchServer
+ .Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Exception("resolve failed"));
+
+ var actions = new List
+ {
+ new Pipelines.ActionStep()
+ {
+ Name = "action",
+ Id = Guid.NewGuid(),
+ Reference = new Pipelines.RepositoryPathReference()
+ {
+ Name = "actions/checkout",
+ Ref = "v4",
+ RepositoryType = "GitHub"
+ }
+ }
+ };
+
+ // Act + Assert
+ await Assert.ThrowsAsync(async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions));
+
+ var telemetryMessages = GetTelemetryMessages();
+ Assert.Equal(1, telemetryMessages.Count(message =>
+ message.Contains("resolve_actions", StringComparison.OrdinalIgnoreCase)
+ && !message.Contains("\"result\":\"succeeded\"", StringComparison.OrdinalIgnoreCase)));
+ Assert.False(ContainsTelemetry(telemetryMessages, "resolve_actions\",\"result\":\"succeeded"));
+ }
+ finally
+ {
+ Teardown();
+ }
+ }
+
#if OS_LINUX
[Fact]
[Trait("Level", "L0")]
@@ -530,9 +585,9 @@ public async void PrepareActions_SymlinkCacheIsReentrant()
//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
{
@@ -2386,7 +2441,7 @@ public void LoadsNode20ActionDefinition()
}
}
- [Fact]
+ [Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LoadsNode24ActionDefinition()
@@ -2454,7 +2509,7 @@ public void LoadsNode24ActionDefinition()
Teardown();
}
}
-
+
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3333,6 +3388,16 @@ private void Teardown()
}
}
+ private IList GetTelemetryMessages()
+ {
+ return _ec.Object.Global.JobTelemetry.Select(x => x.Message).ToList();
+ }
+
+ private static bool ContainsTelemetry(IList telemetryMessages, string expectedFragment)
+ {
+ return telemetryMessages.Any(message => message.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase));
+ }
+
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3468,5 +3533,604 @@ public async Task GetDownloadInfoAsync_OmitsDependencies_WhenEmpty()
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
+ {
+ 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();
+ mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
+ var mockHandlerFactory = new Mock();
+ mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).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
+ {
+ 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(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
+ {
+ 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
+ {
+ 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
+ {
+ 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
+ {
+ 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();
+ mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
+ var mockHandlerFactory = new Mock();
+ mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).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
+ {
+ 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()
+ .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();
+ }
+ }
+
}
}
diff --git a/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs
new file mode 100644
index 00000000000..35f03fdf1f5
--- /dev/null
+++ b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using GitHub.DistributedTask.WebApi;
+using GitHub.Runner.Common.Util;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker;
+using Moq;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class ArtifactsListFileCommandL0
+ {
+ private Mock _executionContext;
+ private string _rootDirectory;
+ private string _outputFile;
+ private ArtifactsListFileCommand _command;
+ private GlobalContext _global;
+ private ITraceWriter _trace;
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EmptyAggregate_WritesVersionedJsonWithEmptySubjects()
+ {
+ using (var hostContext = Setup())
+ {
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+ var json = JObject.Parse(File.ReadAllText(_outputFile));
+ Assert.Equal(ArtifactsListFileCommand.FormatVersion, json["version"].Value());
+ Assert.Empty((JArray)json["subjects"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void SingleSubject_SerializedCorrectly()
+ {
+ using (var hostContext = Setup())
+ {
+ _global.ArtifactSubjects["myapp"] = new ArtifactSubject(
+ "myapp",
+ "sha256:" + new string('a', 64),
+ ArtifactSubjectKind.File);
+
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ var json = JObject.Parse(File.ReadAllText(_outputFile));
+ var subjects = (JArray)json["subjects"];
+ Assert.Single(subjects);
+ Assert.Equal("myapp", subjects[0]["name"].Value());
+ Assert.Equal("sha256:" + new string('a', 64), subjects[0]["digest"].Value());
+ Assert.Equal("file", subjects[0]["kind"].Value());
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_KindIsLowercaseOci()
+ {
+ using (var hostContext = Setup())
+ {
+ _global.ArtifactSubjects["ghcr.io/x:1"] = new ArtifactSubject(
+ "ghcr.io/x:1",
+ "sha256:" + new string('b', 64),
+ ArtifactSubjectKind.OciSubject);
+
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ var json = JObject.Parse(File.ReadAllText(_outputFile));
+ Assert.Equal("oci", json["subjects"][0]["kind"].Value());
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void MultipleSubjects_SortedByName()
+ {
+ using (var hostContext = Setup())
+ {
+ // Insert deliberately out of alphabetical order to prove the
+ // output is sorted by name rather than by insertion order.
+ _global.ArtifactSubjects["two"] = new ArtifactSubject("two", "sha256:" + new string('2', 64), ArtifactSubjectKind.File);
+ _global.ArtifactSubjects["one"] = new ArtifactSubject("one", "sha256:" + new string('1', 64), ArtifactSubjectKind.File);
+ _global.ArtifactSubjects["three"] = new ArtifactSubject("three", "sha256:" + new string('3', 64), ArtifactSubjectKind.OciSubject);
+
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ var subjects = (JArray)JObject.Parse(File.ReadAllText(_outputFile))["subjects"];
+ Assert.Equal(3, subjects.Count);
+ Assert.Equal("one", subjects[0]["name"].Value());
+ Assert.Equal("three", subjects[1]["name"].Value());
+ Assert.Equal("two", subjects[2]["name"].Value());
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FeatureFlagOff_LeavesFileEmpty()
+ {
+ using (var hostContext = Setup(featureFlag: false))
+ {
+ _global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
+
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ Assert.Equal(string.Empty, File.ReadAllText(_outputFile));
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EnvVarFallback_EnablesPublishing()
+ {
+ using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
+ {
+ _global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
+
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ var json = JObject.Parse(File.ReadAllText(_outputFile));
+ Assert.Single((JArray)json["subjects"]);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OutputFileIsUtf8WithoutBom()
+ {
+ using (var hostContext = Setup())
+ {
+ _command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
+
+ var bytes = File.ReadAllBytes(_outputFile);
+ // UTF-8 BOM is EF BB BF
+ Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF,
+ "File should not begin with a UTF-8 BOM.");
+ // Sanity check that the file is valid JSON.
+ Assert.NotNull(JObject.Parse(System.Text.Encoding.UTF8.GetString(bytes)));
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void ProcessCommand_IsNoOp()
+ {
+ using (var hostContext = Setup())
+ {
+ // Even if the step writes garbage, ProcessCommand must not touch the aggregate.
+ File.WriteAllText(_outputFile, "anything the step wrote");
+ _command.ProcessCommand(_executionContext.Object, _outputFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
+ {
+ // Reset env-var state across test runs in the same process.
+ Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
+
+ var hostContext = new TestHostContext(this, name);
+ _trace = hostContext.GetTrace();
+
+ var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
+ Directory.CreateDirectory(workDirectory);
+ _rootDirectory = Path.Combine(workDirectory, nameof(ArtifactsListFileCommandL0), name);
+ if (Directory.Exists(_rootDirectory))
+ {
+ Directory.Delete(_rootDirectory, recursive: true);
+ }
+ Directory.CreateDirectory(_rootDirectory);
+ _outputFile = Path.Combine(_rootDirectory, "artifacts_list");
+ File.WriteAllText(_outputFile, string.Empty);
+
+ var variableValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (featureFlag)
+ {
+ variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue("true");
+ }
+ var variables = new Variables(hostContext, variableValues);
+
+ _global = new GlobalContext
+ {
+ EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer),
+ Variables = variables,
+ WriteDebug = true,
+ ArtifactSubjects = new Dictionary(StringComparer.Ordinal),
+ };
+
+ _executionContext = new Mock();
+ _executionContext.Setup(x => x.Global).Returns(_global);
+ _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny()))
+ .Callback((string tag, string message) =>
+ {
+ _trace.Info($"{tag}{message}");
+ });
+
+ _command = new ArtifactsListFileCommand();
+ _command.Initialize(hostContext);
+
+ return hostContext;
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs
new file mode 100644
index 00000000000..79bcc60890a
--- /dev/null
+++ b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs
@@ -0,0 +1,627 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text;
+using GitHub.DistributedTask.WebApi;
+using GitHub.Runner.Common.Util;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Container;
+using Moq;
+using Xunit;
+using DTWebApi = GitHub.DistributedTask.WebApi;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class CreateArtifactsFileCommandL0
+ {
+ private const string FlagOn = "true";
+
+ private Mock _executionContext;
+ private List _issues;
+ private string _rootDirectory;
+ private string _workspaceDirectory;
+ private CreateArtifactsFileCommand _command;
+ private GlobalContext _global;
+ private ITraceWriter _trace;
+
+ // ---------- Feature flag ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FeatureFlagOff_NoOp()
+ {
+ using (var hostContext = Setup(featureFlag: false))
+ {
+ var artifactsFile = WriteArtifactsFile("ghcr.io/octocat/myapp:1.0@sha256:" + new string('a', 64));
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EnvVarOverride_EnablesFeature()
+ {
+ using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
+ {
+ var hex = new string('a', 64);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EnvVarFalse_DoesNotEnable()
+ {
+ using (var hostContext = Setup(featureFlag: false, envVarOverride: "false"))
+ {
+ var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 64));
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ // ---------- Trivial cases ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileMissing_NoOp()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = Path.Combine(_rootDirectory, "does-not-exist");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EmptyFile_NoOp()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile(string.Empty);
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void BlankAndCommentLines_Skipped()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile(
+ "",
+ "# this is a comment",
+ " # leading-whitespace comment",
+ "",
+ " ");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Empty(_global.ArtifactSubjects);
+ }
+ }
+
+ // ---------- OCI subjects ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_Sha256_HappyPath()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('a', 64);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ var subject = _global.ArtifactSubjects["ghcr.io/octocat/myapp:1.0.0"];
+ Assert.Equal($"sha256:{hex}", subject.Digest);
+ Assert.Equal(ArtifactSubjectKind.OciSubject, subject.Kind);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_Sha384_HappyPath()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('b', 96);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha384:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ Assert.Equal($"sha384:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_Sha512_HappyPath()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('c', 128);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha512:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ Assert.Equal($"sha512:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_DigestLowercased()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('A', 64);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Equal($"sha256:{hex.ToLowerInvariant()}", _global.ArtifactSubjects["ghcr.io/x"].Digest);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_PreservesTagAndRegistryPort()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('d', 64);
+ var artifactsFile = WriteArtifactsFile($"localhost:5000/repo/img:v1@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ Assert.True(_global.ArtifactSubjects.ContainsKey("localhost:5000/repo/img:v1"));
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_WrongHexLength_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ // 63 hex chars instead of 64 → falls back to file path parse → file missing → throws
+ var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 63));
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciSubject_NonHexChars_TreatedAsFile_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ // Non-hex character: digest regex doesn't match → treated as file path → file missing
+ var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('z', 64));
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciScheme_RejectsWhenDigestMissing()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile("oci://ghcr.io/x:1.0");
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("digest", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void OciScheme_HappyPath()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('e', 64);
+ var artifactsFile = WriteArtifactsFile($"OCI://ghcr.io/x:1.0@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/x:1.0"));
+ }
+ }
+
+ // ---------- File subjects ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_Absolute_HappyPath()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactPath = Path.Combine(_rootDirectory, "binary.bin");
+ File.WriteAllBytes(artifactPath, new byte[] { 1, 2, 3, 4 });
+ var artifactsFile = WriteArtifactsFile(artifactPath);
+
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+
+ Assert.Single(_global.ArtifactSubjects);
+ var subject = _global.ArtifactSubjects["binary.bin"];
+ Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
+ // sha256("\x01\x02\x03\x04") = 9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a
+ Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_RelativeToWorkspace()
+ {
+ using (var hostContext = Setup())
+ {
+ Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
+ File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp"), new byte[] { 9 });
+ var artifactsFile = WriteArtifactsFile("dist/myapp");
+
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+
+ Assert.Single(_global.ArtifactSubjects);
+ Assert.True(_global.ArtifactSubjects.ContainsKey("myapp"));
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileScheme_TreatsAsFilePathEvenIfLooksLikeOci()
+ {
+ using (var hostContext = Setup())
+ {
+ // File literally named "image@sha256:deadbeef..." — force file path via file:// prefix.
+ var quirkyName = "image@sha256:" + new string('f', 64);
+ var artifactPath = Path.Combine(_rootDirectory, quirkyName);
+ File.WriteAllBytes(artifactPath, new byte[] { 1 });
+ var artifactsFile = WriteArtifactsFile("file://" + artifactPath);
+
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+
+ Assert.Single(_global.ArtifactSubjects);
+ var subject = _global.ArtifactSubjects[quirkyName];
+ Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_Missing_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile(Path.Combine(_rootDirectory, "does-not-exist"));
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("does not exist", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_Directory_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ var dir = Path.Combine(_rootDirectory, "a-directory");
+ Directory.CreateDirectory(dir);
+ var artifactsFile = WriteArtifactsFile(dir);
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("not a regular file", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_ContainerAbsolute_InMount_ResolvesToHostFile()
+ {
+ using (var hostContext = Setup())
+ {
+ // The host file lives under a directory that is mounted into
+ // the container. The step declares the file using its
+ // container-namespace path, which must translate back to the
+ // host file so the digest is computed over the right bytes.
+ var hostDirectory = Path.Combine(_rootDirectory, "mounted");
+ Directory.CreateDirectory(hostDirectory);
+ File.WriteAllBytes(Path.Combine(hostDirectory, "app.bin"), new byte[] { 1, 2, 3, 4 });
+
+ var container = new ContainerInfo();
+ var containerDirectory = "/container-workspace";
+ container.AddPathTranslateMapping(hostDirectory, containerDirectory);
+
+ var artifactsFile = WriteArtifactsFile(Path.Combine(containerDirectory, "app.bin"));
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, container);
+
+ Assert.Single(_global.ArtifactSubjects);
+ var subject = _global.ArtifactSubjects["app.bin"];
+ Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
+ // sha256("\x01\x02\x03\x04")
+ Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileSubject_ContainerAbsolute_OutsideMount_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ // An absolute path that does not resolve into any mounted
+ // volume must be rejected rather than silently hashing the
+ // host file that happens to live at that same path.
+ var container = new ContainerInfo();
+ container.AddPathTranslateMapping(Path.Combine(_rootDirectory, "mounted"), "/container-workspace");
+
+ var artifactsFile = WriteArtifactsFile(Path.Combine("/unmapped-container-dir", "secret.bin"));
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, container));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("not inside a volume mounted", ex.Message);
+ }
+ }
+
+ // ---------- Format / scheme rules ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void EqualsSign_Rejected()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile("name=ghcr.io/x@sha256:" + new string('a', 64));
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("'='", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void UnsupportedScheme_Rejected()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = WriteArtifactsFile("https://example.com/artifact");
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 1", ex.Message);
+ Assert.Contains("unsupported URI scheme", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void LineNumberInError_PointsAtRightLine()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('a', 64);
+ var artifactsFile = WriteArtifactsFile(
+ "# comment",
+ $"ghcr.io/ok@sha256:{hex}",
+ "",
+ "name=bogus");
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("line 4", ex.Message);
+ }
+ }
+
+ // ---------- Size and aggregate limits ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void FileTooLarge_Throws()
+ {
+ using (var hostContext = Setup())
+ {
+ var artifactsFile = Path.Combine(_rootDirectory, "huge");
+ // Slightly larger than 1 MiB.
+ File.WriteAllBytes(artifactsFile, new byte[CreateArtifactsFileCommand.MaxFileSizeBytes + 1]);
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("$GITHUB_ARTIFACTS", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void AggregateCap_AllowsDuplicatesAtCap()
+ {
+ using (var hostContext = Setup())
+ {
+ // Pre-fill the aggregate to exactly the cap.
+ var hex = new string('a', 64);
+ for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
+ {
+ var name = $"ghcr.io/x{i}";
+ _global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
+ }
+
+ // A new step redeclares one of the existing artifacts identically — should NOT throw.
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x0@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Equal(CreateArtifactsFileCommand.MaxAggregateArtifacts, _global.ArtifactSubjects.Count);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void AggregateCap_FailsOnFirstDistinctOverflow()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('a', 64);
+ for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
+ {
+ var name = $"ghcr.io/x{i}";
+ _global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
+ }
+
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/new@sha256:{hex}");
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("500", ex.Message);
+ }
+ }
+
+ // ---------- Aggregation rules ----------
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Aggregation_DedupsIdentical()
+ {
+ using (var hostContext = Setup())
+ {
+ var hex = new string('a', 64);
+ _global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+ Assert.Single(_global.ArtifactSubjects);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void Aggregation_FailsOnConflict()
+ {
+ using (var hostContext = Setup())
+ {
+ var hexA = new string('a', 64);
+ var hexB = new string('b', 64);
+ _global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hexA}", ArtifactSubjectKind.OciSubject);
+ var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hexB}");
+ var ex = Assert.Throws(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
+ Assert.Contains("Conflicting digest", ex.Message);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void MultipleEntries_AllProcessed()
+ {
+ using (var hostContext = Setup())
+ {
+ Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
+ File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp-linux-amd64"), new byte[] { 7 });
+
+ var hex = new string('a', 64);
+ var artifactsFile = WriteArtifactsFile(
+ "# Release binary",
+ "dist/myapp-linux-amd64",
+ "",
+ "# Published container image",
+ $"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
+
+ _command.ProcessCommand(_executionContext.Object, artifactsFile, null);
+
+ Assert.Equal(2, _global.ArtifactSubjects.Count);
+ Assert.True(_global.ArtifactSubjects.ContainsKey("myapp-linux-amd64"));
+ Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/octocat/myapp:1.0.0"));
+ }
+ }
+
+ // ---------- Setup helpers ----------
+
+ private string WriteArtifactsFile(params string[] lines)
+ {
+ var path = Path.Combine(_rootDirectory, "artifacts");
+ File.WriteAllText(path, string.Join("\n", lines), new UTF8Encoding(false));
+ return path;
+ }
+
+ private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
+ {
+ _issues = new List();
+
+ // Ensure no leaked state from prior tests in the same process.
+ Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
+
+ var hostContext = new TestHostContext(this, name);
+ _trace = hostContext.GetTrace();
+
+ var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
+ Directory.CreateDirectory(workDirectory);
+ _rootDirectory = Path.Combine(workDirectory, nameof(CreateArtifactsFileCommandL0), name);
+ if (Directory.Exists(_rootDirectory))
+ {
+ Directory.Delete(_rootDirectory, recursive: true);
+ }
+ Directory.CreateDirectory(_rootDirectory);
+
+ _workspaceDirectory = Path.Combine(_rootDirectory, "workspace");
+ Directory.CreateDirectory(_workspaceDirectory);
+
+ var variableValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (featureFlag)
+ {
+ variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue(FlagOn);
+ }
+ var variables = new Variables(hostContext, variableValues);
+
+ _global = new GlobalContext
+ {
+ EnvironmentVariables = new Dictionary(VarUtil.EnvironmentVariableKeyComparer),
+ Variables = variables,
+ WriteDebug = true,
+ ArtifactSubjects = new Dictionary(StringComparer.Ordinal),
+ };
+
+ _executionContext = new Mock();
+ _executionContext.Setup(x => x.Global).Returns(_global);
+ _executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(_workspaceDirectory);
+ _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny()))
+ .Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
+ {
+ _issues.Add(issue);
+ _trace.Info($"Issue '{issue.Type}': {issue.Message}");
+ });
+ _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny()))
+ .Callback((string tag, string message) =>
+ {
+ _trace.Info($"{tag}{message}");
+ });
+
+ _command = new CreateArtifactsFileCommand();
+ _command.Initialize(hostContext);
+
+ return hostContext;
+ }
+ }
+}
diff --git a/src/Test/L0/Worker/FileCommandManagerL0.cs b/src/Test/L0/Worker/FileCommandManagerL0.cs
new file mode 100644
index 00000000000..e09253403d5
--- /dev/null
+++ b/src/Test/L0/Worker/FileCommandManagerL0.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using GitHub.Runner.Common;
+using GitHub.Runner.Sdk;
+using GitHub.Runner.Worker;
+using GitHub.Runner.Worker.Container;
+using Moq;
+using Xunit;
+
+namespace GitHub.Runner.Common.Tests.Worker
+{
+ public sealed class FileCommandManagerL0
+ {
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void InitializeFiles_InvokesPopulateInitialContents_OncePerExtension()
+ {
+ using (var hostContext = Setup(out var executionContext, out var ext))
+ {
+ var manager = new FileCommandManager();
+ manager.Initialize(hostContext);
+
+ manager.InitializeFiles(executionContext, null);
+
+ Assert.Equal(1, ext.PopulateCallCount);
+ Assert.True(File.Exists(ext.LastPopulatedPath));
+
+ // A second invocation should populate again with the new
+ // per-step file (file path rotates between calls).
+ var firstPath = ext.LastPopulatedPath;
+ manager.InitializeFiles(executionContext, null);
+ Assert.Equal(2, ext.PopulateCallCount);
+ Assert.NotEqual(firstPath, ext.LastPopulatedPath);
+ }
+ }
+
+ [Fact]
+ [Trait("Level", "L0")]
+ [Trait("Category", "Worker")]
+ public void InitializeFiles_PopulateException_DoesNotAbortInitialization()
+ {
+ using (var hostContext = Setup(out var executionContext, out var ext))
+ {
+ ext.ThrowOnPopulate = true;
+
+ var manager = new FileCommandManager();
+ manager.Initialize(hostContext);
+
+ // Must not throw — failures during populate should be
+ // swallowed so a misbehaving extension cannot block step
+ // setup.
+ manager.InitializeFiles(executionContext, null);
+
+ Assert.Equal(1, ext.PopulateCallCount);
+ }
+ }
+
+ private TestHostContext Setup(out IExecutionContext executionContext, out RecordingFileCommand recordingExtension, [CallerMemberName] string name = "")
+ {
+ var hostContext = new TestHostContext(this, name);
+
+ recordingExtension = new RecordingFileCommand();
+ recordingExtension.Initialize(hostContext);
+
+ var extensionManager = new Mock();
+ extensionManager.Setup(x => x.GetExtensions())
+ .Returns(new List { recordingExtension });
+ hostContext.SetSingleton(extensionManager.Object);
+
+ var ec = new Mock();
+ ec.Setup(x => x.SetGitHubContext(It.IsAny(), It.IsAny()));
+ executionContext = ec.Object;
+
+ return hostContext;
+ }
+
+ private sealed class RecordingFileCommand : RunnerService, IFileCommandExtension
+ {
+ public string ContextName => "recording";
+ public string FilePrefix => "recording_";
+ public Type ExtensionType => typeof(IFileCommandExtension);
+
+ public int PopulateCallCount { get; private set; }
+ public string LastPopulatedPath { get; private set; }
+ public bool ThrowOnPopulate { get; set; }
+
+ public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
+ {
+ PopulateCallCount++;
+ LastPopulatedPath = filePath;
+ if (ThrowOnPopulate)
+ {
+ throw new InvalidOperationException("intentional");
+ }
+ }
+
+ public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
+ {
+ }
+ }
+ }
+}
diff --git a/src/dev.sh b/src/dev.sh
index fafdbffb360..7d9aa58dd79 100755
--- a/src/dev.sh
+++ b/src/dev.sh
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
-DOTNETSDK_VERSION="8.0.421"
+DOTNETSDK_VERSION="8.0.422"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)
diff --git a/src/global.json b/src/global.json
index 12e63e7d30c..a928aa42b4a 100644
--- a/src/global.json
+++ b/src/global.json
@@ -1,5 +1,5 @@
{
"sdk": {
- "version": "8.0.421"
+ "version": "8.0.422"
}
}
]