Compare commits

...

8 Commits

Author SHA1 Message Date
Jeff Martin
dde968bf57 feat: add dollar-self action reference syntax (#4457) 2026-07-02 21:35:54 +00:00
Allan Guigou
0e31cd5ff7 Update Docker version to 29.6.1 (#4539) 2026-07-02 15:52:34 -04:00
Tingluo Huang
4c6d85cfc0 feat: enhance telemetry for action download resolution and failures (#4536) 2026-07-01 17:29:02 -04:00
github-actions[bot]
1ed4f70ee9 chore: update Node versions (#4530)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-29 10:27:28 -04:00
github-actions[bot]
c814d7ca46 chore: update Node versions (#4519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 13:48:51 +00:00
github-actions[bot]
302ff10861 Update Docker to v29.6.0 and Buildx to v0.35.0 (#4516)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 13:35:29 +00:00
dependabot[bot]
74aa458a12 Bump actions/checkout from 6 to 7 (#4511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 16:20:11 +08:00
Tingluo Huang
c057cc3886 Report actions archive size in telemetry. (#4509) 2026-06-17 11:49:15 -04:00
19 changed files with 1022 additions and 52 deletions

View File

@@ -53,7 +53,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Build runner layout
- name: Build & Layout Release
@@ -95,7 +95,7 @@ jobs:
docker_platform: linux/arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Get latest runner version
id: latest_runner

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -29,7 +29,7 @@ jobs:
npm-vulnerabilities: ${{ steps.check-versions.outputs.npm-vulnerabilities }}
open-dependency-prs: ${{ steps.check-prs.outputs.open-dependency-prs }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
with:

View File

@@ -17,7 +17,7 @@ jobs:
BUILDX_CURRENT_VERSION: ${{ steps.check_buildx_version.outputs.CURRENT_VERSION }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Check Docker version
id: check_docker_version
@@ -89,7 +89,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Update Docker version
shell: bash

View File

@@ -20,7 +20,7 @@ jobs:
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.releaseBranch }}

View File

@@ -15,7 +15,7 @@ jobs:
DOTNET_CURRENT_MAJOR_MINOR_VERSION: ${{ steps.fetch_current_version.outputs.DOTNET_CURRENT_MAJOR_MINOR_VERSION }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Get current major minor version
id: fetch_current_version
shell: bash
@@ -89,7 +89,7 @@ jobs:
if: ${{ needs.dotnet-update.outputs.SHOULD_UPDATE == 1 && needs.dotnet-update.outputs.BRANCH_EXISTS == 0 }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: feature/dotnetsdk-upgrade/${{ needs.dotnet-update.outputs.DOTNET_LATEST_MAJOR_MINOR_PATCH_VERSION }}
- name: Create Pull Request

View File

@@ -9,7 +9,7 @@ jobs:
update-node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Get latest Node versions
id: node-versions
run: |

View File

@@ -7,7 +7,7 @@ jobs:
npm-audit-with-ts-fix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
with:

View File

@@ -9,7 +9,7 @@ jobs:
npm-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -11,7 +11,7 @@ jobs:
if: startsWith(github.ref, 'refs/heads/releases/') || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Make sure ./releaseVersion match ./src/runnerversion
# Query GitHub release ensure version is not used
@@ -86,7 +86,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Build runner layout
- name: Build & Layout Release
@@ -129,7 +129,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Download runner package tar.gz/zip produced by 'build' job
- name: Download Artifact (win-x64)
@@ -296,7 +296,7 @@ jobs:
IMAGE_NAME: ${{ github.repository_owner }}/actions-runner
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Compute image version
id: image

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.5.3
ARG BUILDX_VERSION=0.34.1
ARG DOCKER_VERSION=29.6.1
ARG BUILDX_VERSION=0.35.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -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.16.0"
NODE24_VERSION="24.18.0"
get_abs_path() {
# exploits the fact that pwd will print abs path when no args

View File

@@ -180,6 +180,7 @@ namespace GitHub.Runner.Common
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
public static readonly string SelfRepository = "actions_self_repository";
}
// Node version migration related constants

View File

@@ -178,7 +178,7 @@ namespace GitHub.Runner.Worker
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
}
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
@@ -186,6 +186,21 @@ namespace GitHub.Runner.Worker
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
}
// Resolve self-repository ($/) references before processing
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
{
if (string.IsNullOrEmpty(selfRepoName))
{
// job.workflow_repository/workflow_sha point to the repo
// containing the workflow file — correct for both regular
// and reusable workflows. Always present when the server
// supports $/. See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
selfRepoName = executionContext.JobContext?.WorkflowRepository;
selfRepoRef = executionContext.JobContext?.WorkflowSha;
}
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
}
var repositoryActions = new List<Pipelines.ActionStep>();
foreach (var action in actions)
@@ -228,7 +243,30 @@ namespace GitHub.Runner.Worker
{
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 @@ namespace GitHub.Runner.Worker
// then recurse per parent (which hits the cache, not the API).
if (nextLevel.Count > 0)
{
var nextLevelRepoActions = nextLevel
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
.Select(x => x.action)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var group in nextLevel.GroupBy(x => x.parentId))
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
{
var groupActions = group.Select(x => x.action).ToList();
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
// Self-repository path: group by parent so each group's
// $/ refs resolve against the correct parent repo context.
var groups = nextLevel.GroupBy(x => x.parentId).Select(group =>
{
string childRepoName = selfRepoName;
string childRepoRef = selfRepoRef;
var parentAction = repositoryActions.FirstOrDefault(a => a.Id == group.Key);
if (parentAction?.Reference is Pipelines.RepositoryPathReference parentRef &&
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
{
childRepoName = parentRef.Name;
childRepoRef = parentRef.Ref;
}
return new { ParentId = group.Key, Actions = group.Select(x => x.action).ToList(), RepoName = childRepoName, RepoRef = childRepoRef };
}).ToList();
foreach (var group in groups)
{
ResolveSelfRepositoryReferences(executionContext, group.Actions, group.RepoName, group.RepoRef);
}
var nextLevelRepoActions = nextLevel
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
.Select(x => x.action)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var group in groups)
{
state = await PrepareActionsRecursiveAsync(executionContext, state, group.Actions, resolvedDownloadInfos, depth + 1, group.ParentId, group.RepoName, group.RepoRef);
}
}
else
{
// Original path: no self-repository resolution needed.
var nextLevelActions = nextLevel.Select(x => x.action).ToList();
var nextLevelRepoActions = nextLevelActions
.Where(x => x.Reference.Type == Pipelines.ActionSourceType.Repository)
.ToList();
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
foreach (var grp in nextLevel.GroupBy(x => x.parentId))
{
state = await PrepareActionsRecursiveAsync(executionContext, state, grp.Select(x => x.action).ToList(), resolvedDownloadInfos, depth + 1, grp.Key);
}
}
}
@@ -363,13 +438,25 @@ namespace GitHub.Runner.Worker
/// sub-actions individually, with no cross-depth deduplication.
/// Used when the BatchActionResolution feature flag is disabled.
/// </summary>
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid), string selfRepoName = null, string selfRepoRef = null)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
{
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
}
// Resolve self-repository ($/) references before processing
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
{
if (string.IsNullOrEmpty(selfRepoName))
{
selfRepoName = executionContext.JobContext?.WorkflowRepository;
selfRepoRef = executionContext.JobContext?.WorkflowSha;
}
ResolveSelfRepositoryReferences(executionContext, actions, selfRepoName, selfRepoRef);
}
var repositoryActions = new List<Pipelines.ActionStep>();
foreach (var action in actions)
@@ -398,7 +485,30 @@ namespace GitHub.Runner.Worker
if (repositoryActions.Count > 0)
{
// Get the download info
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
IDictionary<string, WebApi.ActionDownloadInfo> 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 @@ namespace GitHub.Runner.Worker
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 @@ namespace GitHub.Runner.Worker
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
// Propagate parent's repo context for nested self-repository resolution
var parentRef = action.Reference as Pipelines.RepositoryPathReference;
var childRepoName = selfRepoName;
var childRepoRef = selfRepoRef;
if (parentRef != null &&
string.Equals(parentRef.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
{
childRepoName = parentRef.Name;
childRepoRef = parentRef.Ref;
}
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id, childRepoName, childRepoRef);
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
@@ -562,6 +704,12 @@ namespace GitHub.Runner.Worker
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
}
}
else if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
{
// Unresolved self-repository reference at load time — this
// shouldn't happen but guard against NRE if it does.
throw new InvalidOperationException($"Self-repository reference '$/{repoAction.Path}' was not resolved before LoadAction. Ensure the '{Constants.Runner.Features.SelfRepository}' feature flag is enabled.");
}
else
{
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
@@ -697,6 +845,27 @@ namespace GitHub.Runner.Worker
_cachedEmbeddedStepIds[action.Id].Add(guid);
}
}
// Resolve self-repository refs in composite steps at load time.
// During setup, resolution happens on a separate copy of these
// step objects. At runtime, action.yml is re-parsed, producing
// fresh self-repository refs that need resolution here.
// When the parent is a dot-slash (self local-workspace) action,
// repoAction.Name/Ref are null — fall back to workflow context.
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
{
var parentName = repoAction.Name ?? executionContext.JobContext?.WorkflowRepository;
var parentRef = repoAction.Ref ?? executionContext.JobContext?.WorkflowSha;
ResolveSelfRepositoryReferences(executionContext, compositeAction.Steps, parentName, parentRef);
if (compositeAction.PreSteps != null)
{
ResolveSelfRepositoryReferences(executionContext, compositeAction.PreSteps, parentName, parentRef);
}
if (compositeAction.PostSteps != null)
{
ResolveSelfRepositoryReferences(executionContext, compositeAction.PostSteps, parentName, parentRef);
}
}
}
else
{
@@ -980,10 +1149,33 @@ namespace GitHub.Runner.Worker
if (actionsToResolve.Count > 0)
{
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
foreach (var kvp in downloadInfos)
IDictionary<string, WebApi.ActionDownloadInfo> 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)}"
});
}
}
}
@@ -1108,12 +1300,6 @@ namespace GitHub.Runner.Worker
}
}
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
{
Type = JobTelemetryType.General,
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}"
});
if (!useActionArchiveCache)
{
await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile);
@@ -1122,6 +1308,13 @@ namespace GitHub.Runner.Worker
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
Directory.CreateDirectory(stagingDirectory);
var fileInfo = new FileInfo(archiveFile);
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
{
Type = JobTelemetryType.General,
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} size {fileInfo.Length} bytes"
});
#if OS_WINDOWS
try
{
@@ -1159,7 +1352,6 @@ namespace GitHub.Runner.Worker
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
if (exitCode != 0)
{
var fileInfo = new FileInfo(archiveFile);
var sha256hash = await IOUtil.GetFileContentSha256HashAsync(archiveFile);
throw new InvalidActionArchiveException($"Can't use 'tar -xzf' extract archive file: {archiveFile} (SHA256 '{sha256hash}', size '{fileInfo.Length}' bytes, tar outputs '{string.Join(' ', tarOutputs)}'). Action being checked out: {downloadInfo.NameWithOwner}@{downloadInfo.Ref}. return code: {exitCode}.");
}
@@ -1209,6 +1401,12 @@ namespace GitHub.Runner.Worker
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 @@ namespace GitHub.Runner.Worker
}
}
/// <summary>
/// Resolves self-reference ($/) references by mutating them in-place
/// to standard GitHub repository references with the containing repo's
/// name and ref.
/// </summary>
private void ResolveSelfRepositoryReferences(IExecutionContext executionContext, IEnumerable<Pipelines.ActionStep> actions, string repoName, string repoRef)
{
if (string.IsNullOrEmpty(repoName) || string.IsNullOrEmpty(repoRef))
{
return;
}
foreach (var action in actions)
{
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
{
continue;
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (!string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Trace.Info($"Resolving self-repository reference reference '$/{repoAction.Path}' to '{repoName}/{repoAction.Path}@{repoRef}'");
executionContext.Debug($"Resolving $/{repoAction.Path} → {repoName}/{repoAction.Path}@{repoRef}");
repoAction.RepositoryType = Pipelines.RepositoryTypes.GitHub;
repoAction.Name = repoName;
repoAction.Ref = repoRef;
}
}
/// <summary>
/// If this is a reusable workflow job, ensure the workflow repo tarball
/// is downloaded so self.workspace resolves to a real path on disk.
/// Always downloads for reusable workflows when the feature flag is on,
/// since step expressions are already expanded by the server and can't
/// be scanned for self.* usage.
/// </summary>
private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action)
{
if (action.Reference.Type != Pipelines.ActionSourceType.Repository)
@@ -1362,6 +1601,11 @@ namespace GitHub.Runner.Worker
return null;
}
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.");
}
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException(repositoryReference.RepositoryType);

View File

@@ -55,7 +55,18 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
break;
case ActionSourceType.Repository:
var repositoryReference = step.Reference as RepositoryPathReference;
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
if (!String.IsNullOrEmpty(repositoryReference.Name))
{
name = repositoryReference.Name;
}
else if (String.Equals(repositoryReference.RepositoryType, PipelineConstants.SelfRepositoryAlias, StringComparison.OrdinalIgnoreCase))
{
name = PipelineConstants.SelfRepositoryAlias;
}
else
{
name = PipelineConstants.SelfAlias;
}
break;
}
@@ -600,6 +611,14 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
Path = uses.Value
};
}
else if (PipelineConstants.TryParseSelfRepository(uses.Value, out var selfPath))
{
result.Reference = new RepositoryPathReference
{
RepositoryType = PipelineConstants.SelfRepositoryAlias,
Path = selfPath
};
}
else
{
var usesSegments = uses.Value.Split('@');

View File

@@ -38,10 +38,43 @@ namespace GitHub.DistributedTask.Pipelines
public static readonly Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self repository.
/// Alias for the self local-workspace repository type (./ syntax).
/// Resolves to the local checkout on the runner.
/// </summary>
public static readonly String SelfAlias = "self";
/// <summary>
/// RepositoryType for self-repository references ($/ syntax).
/// Resolves to "this repo, at this SHA" based on the containing YAML file.
/// </summary>
public static readonly String SelfRepositoryAlias = "selfRepository";
/// <summary>
/// The prefix for self-repository references in uses: values.
/// </summary>
public const String SelfRepositoryPrefix = "$/";
/// <summary>
/// Returns true if the uses value is a self-repository reference (starts with $/),
/// and outputs the subpath after the prefix.
/// </summary>
public static bool TryParseSelfRepository(string usesValue, out string path)
{
if (usesValue != null && usesValue.StartsWith(SelfRepositoryPrefix, StringComparison.Ordinal))
{
path = usesValue.Substring(SelfRepositoryPrefix.Length).TrimStart('/');
if (string.IsNullOrEmpty(path))
{
path = null;
return false;
}
return true;
}
path = null;
return false;
}
/// <summary>
/// Error code during graph validation.
/// </summary>

View File

@@ -1605,6 +1605,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
{
id = WorkflowConstants.SelfAlias;
}
else if (GitHub.DistributedTask.Pipelines.PipelineConstants.TryParseSelfRepository(action.Uses!.Value, out _))
{
id = WorkflowConstants.SelfRepositoryAlias;
}
else
{
var usesSegments = action.Uses!.Value.Split('@');

View File

@@ -26,10 +26,15 @@ namespace GitHub.Actions.WorkflowParser
internal const Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self repository.
/// Alias for the self local-workspace repository type (./ syntax).
/// </summary>
internal const String SelfAlias = "self";
/// <summary>
/// RepositoryType for self-repository references ($/ syntax).
/// </summary>
internal const String SelfRepositoryAlias = "selfRepository";
public static class PermissionsPolicy
{
public const string LimitedRead = "LimitedRead";

View File

@@ -90,6 +90,11 @@ namespace GitHub.Runner.Common.Tests.Worker
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 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act + Assert
await Assert.ThrowsAsync<InvalidActionArchiveException>(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 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[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<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
.ThrowsAsync(new Exception("resolve failed"));
var actions = new List<Pipelines.JobStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = "actions/checkout",
Ref = "v4",
RepositoryType = "GitHub"
}
}
};
// Act + Assert
await Assert.ThrowsAsync<FailedToResolveActionDownloadInfoException>(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 @@ runs:
//Assert
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
var di = new DirectoryInfo(destDirectory);
Assert.NotNull(di.LinkTarget);
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
var di = new DirectoryInfo(destDirectory);
Assert.NotNull(di.LinkTarget);
}
finally
{
@@ -2386,7 +2441,7 @@ runs:
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LoadsNode24ActionDefinition()
@@ -2454,7 +2509,7 @@ runs:
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3333,6 +3388,16 @@ runs:
}
}
private IList<string> GetTelemetryMessages()
{
return _ec.Object.Global.JobTelemetry.Select(x => x.Message).ToList();
}
private static bool ContainsTelemetry(IList<string> telemetryMessages, string expectedFragment)
{
return telemetryMessages.Any(message => message.Contains(expectedFragment, StringComparison.OrdinalIgnoreCase));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3468,5 +3533,604 @@ runs:
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero()
{
// Self-references are only supported via run service (batch resolution path)
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
// Arrange
Setup();
const string RepoName = "my-org/my-repo";
const string RepoSha = "abc123def456";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RepoName;
jobContext.WorkflowSha = RepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "actions/my-action"
}
}
};
string archiveFile = await CreateRepoArchive();
using var stream = File.OpenRead(archiveFile);
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
var mockClientHandler = new Mock<HttpClientHandler>();
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
_hc.SetSingleton(mockHandlerFactory.Object);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
// Act — resolution mutates the reference in-place before download/prepare.
// The archive doesn't contain the subpath, so prepare will fail, but the
// reference is already resolved by that point.
try
{
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
{
// Expected: test archive lacks the action.yml at the resolved subpath
}
// Assert — the reference should be resolved to a GitHub repo reference
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
Assert.Equal(RepoName, repoRef.Name);
Assert.Equal(RepoSha, repoRef.Ref);
Assert.Equal("actions/my-action", repoRef.Path);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_NotResolvedWhenFeatureFlagDisabled()
{
try
{
// Arrange
Setup();
_ec.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/my-repo");
_ec.Setup(x => x.GetGitHubContext("sha")).Returns("abc123");
// Feature flag NOT set
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "actions/my-action"
}
}
};
// Act & Assert — should throw because unresolved self-reference hits GetDownloadInfoLookupKey
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await _actionManager.PrepareActionsAsync(_ec.Object, actions));
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite()
{
// Composite action at $/actions/parent uses $/actions/child (same repo).
// This tests the batch path fix: $/ refs in nextLevel must be resolved
// BEFORE ResolveNewActionsAsync, otherwise GetDownloadInfoLookupKey throws.
// We pre-stage only the parent action.yml on disk so the composite steps
// are discovered, but we DON'T stage the child — a download failure for
// the child is fine; the important thing is that $/ was resolved
// (no InvalidOperationException from GetDownloadInfoLookupKey).
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
// Arrange
Setup();
const string RepoName = "my-org/my-repo";
const string RepoSha = "abc123def456";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RepoName;
jobContext.WorkflowSha = RepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
// Stage parent action on disk as a composite that uses $/actions/child.
// We use rootStepId != default to avoid directory deletion,
// and create the watermark + action.yml in the expected location.
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
name: 'Parent'
description: 'Composite parent'
runs:
using: 'composite'
steps:
- uses: $/actions/child
");
// Stage child action too (as a leaf node action)
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
name: 'Child'
description: 'Node child'
runs:
using: 'node20'
main: 'index.js'
");
// Write watermark
File.WriteAllText($"{destDir}.completed", string.Empty);
var rootStepId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "actions/parent"
}
}
};
// Act — should resolve $/ and not throw InvalidOperationException
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
// Assert — top-level $/ resolved
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
Assert.Equal(RepoName, topRef.Name);
Assert.Equal(RepoSha, topRef.Ref);
Assert.Equal("actions/parent", topRef.Path);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_CrossRepoCompositeResolvesToParentRepo()
{
// External composite (external/foo@v1) uses $/lib/bar.
// $/lib/bar should resolve to external/foo@v1 (the parent's repo),
// NOT to the workflow's root repo.
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
// Arrange
Setup();
const string RootRepoName = "my-org/my-repo";
const string RootRepoSha = "root-sha-111";
const string ExtRepoName = "external/foo";
const string ExtRepoRef = "v1";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RootRepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RootRepoSha);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RootRepoName;
jobContext.WorkflowSha = RootRepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
string destDir = Path.Combine(actionsDir, ExtRepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), ExtRepoRef);
Directory.CreateDirectory(destDir);
File.WriteAllText(Path.Combine(destDir, Constants.Path.ActionManifestYmlFile), @"
name: 'External Foo'
description: 'External composite'
runs:
using: 'composite'
steps:
- uses: $/lib/bar
");
Directory.CreateDirectory(Path.Combine(destDir, "lib", "bar"));
File.WriteAllText(Path.Combine(destDir, "lib", "bar", Constants.Path.ActionManifestYmlFile), @"
name: 'Bar'
description: 'Node action in external repo'
runs:
using: 'node20'
main: 'index.js'
");
File.WriteAllText($"{destDir}.completed", string.Empty);
var rootStepId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = ExtRepoName,
Ref = ExtRepoRef,
RepositoryType = Pipelines.RepositoryTypes.GitHub
}
}
};
// Act — should resolve $/lib/bar to external/foo@v1/lib/bar
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
// Assert — the top-level ref is unchanged (it was already concrete)
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(ExtRepoName, topRef.Name);
Assert.Equal(ExtRepoRef, topRef.Ref);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_MultiLevelChain()
{
// $/a → composite → $/b → composite → $/c (three levels, same repo)
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", "true");
try
{
// Arrange
Setup();
const string RepoName = "my-org/my-repo";
const string RepoSha = "chain-sha-222";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RepoName;
jobContext.WorkflowSha = RepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
Directory.CreateDirectory(Path.Combine(destDir, "a"));
File.WriteAllText(Path.Combine(destDir, "a", Constants.Path.ActionManifestYmlFile), @"
name: 'A'
description: 'Level 0 composite'
runs:
using: 'composite'
steps:
- uses: $/b
");
Directory.CreateDirectory(Path.Combine(destDir, "b"));
File.WriteAllText(Path.Combine(destDir, "b", Constants.Path.ActionManifestYmlFile), @"
name: 'B'
description: 'Level 1 composite'
runs:
using: 'composite'
steps:
- uses: $/c
");
Directory.CreateDirectory(Path.Combine(destDir, "c"));
File.WriteAllText(Path.Combine(destDir, "c", Constants.Path.ActionManifestYmlFile), @"
name: 'C'
description: 'Level 2 node leaf'
runs:
using: 'node20'
main: 'index.js'
");
File.WriteAllText($"{destDir}.completed", string.Empty);
var rootStepId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "a"
}
}
};
// Act — three-level $/ chain should resolve without error
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
// Assert — top-level ref resolved
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
Assert.Equal(RepoName, topRef.Name);
Assert.Equal(RepoSha, topRef.Ref);
Assert.Equal("a", topRef.Path);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION", null);
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_ResolvesAtDepthZero_LegacyPath()
{
// Same as ResolvesAtDepthZero but on the legacy (non-batch) path
try
{
// Arrange
Setup();
const string RepoName = "my-org/my-repo";
const string RepoSha = "abc123def456";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RepoName;
jobContext.WorkflowSha = RepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
var actionId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = actionId,
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "actions/my-action"
}
}
};
string archiveFile = await CreateRepoArchive();
using var stream = File.OpenRead(archiveFile);
string archiveLink = GetLinkToActionArchive("https://api.github.com", RepoName, RepoSha);
var mockClientHandler = new Mock<HttpClientHandler>();
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(archiveLink)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });
var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
_hc.SetSingleton(mockHandlerFactory.Object);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
// Act
try
{
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Can't find"))
{
// Expected: test archive lacks the action.yml at the resolved subpath
}
// Assert — the reference should be resolved to a GitHub repo reference
var repoRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, repoRef.RepositoryType);
Assert.Equal(RepoName, repoRef.Name);
Assert.Equal(RepoSha, repoRef.Ref);
Assert.Equal("actions/my-action", repoRef.Path);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async void PrepareActions_SelfRepository_ResolvesNestedInComposite_LegacyPath()
{
// Same as ResolvesNestedInComposite but on the legacy (non-batch) path.
// Verifies that $/ resolution works when batch action resolution is disabled.
try
{
// Arrange
Setup();
const string RepoName = "my-org/my-repo";
const string RepoSha = "abc123def456";
_ec.Setup(x => x.GetGitHubContext("repository")).Returns(RepoName);
_ec.Setup(x => x.GetGitHubContext("sha")).Returns(RepoSha);
_ec.Setup(x => x.GetGitHubContext("api_url")).Returns("https://api.github.com");
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = RepoName;
jobContext.WorkflowSha = RepoSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
// Stage parent action on disk as a composite that uses $/actions/child.
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
string destDir = Path.Combine(actionsDir, RepoName.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), RepoSha);
Directory.CreateDirectory(Path.Combine(destDir, "actions", "parent"));
File.WriteAllText(Path.Combine(destDir, "actions", "parent", Constants.Path.ActionManifestYmlFile), @"
name: 'Parent'
description: 'Composite parent'
runs:
using: 'composite'
steps:
- uses: $/actions/child
");
// Stage child action too (as a leaf node action)
Directory.CreateDirectory(Path.Combine(destDir, "actions", "child"));
File.WriteAllText(Path.Combine(destDir, "actions", "child", Constants.Path.ActionManifestYmlFile), @"
name: 'Child'
description: 'Node child'
runs:
using: 'node20'
main: 'index.js'
");
// Write watermark
File.WriteAllText($"{destDir}.completed", string.Empty);
var rootStepId = Guid.NewGuid();
var actions = new List<Pipelines.ActionStep>
{
new Pipelines.ActionStep()
{
Name = "action",
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
RepositoryType = Pipelines.PipelineConstants.SelfRepositoryAlias,
Path = "actions/parent"
}
}
};
// Act — should resolve $/ and not throw InvalidOperationException
await _actionManager.PrepareActionsAsync(_ec.Object, actions, rootStepId);
// Assert — top-level $/ resolved
var topRef = actions[0].Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, topRef.RepositoryType);
Assert.Equal(RepoName, topRef.Name);
Assert.Equal(RepoSha, topRef.Ref);
Assert.Equal("actions/parent", topRef.Path);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LoadAction_DotSlashCompositeWithNestedSelfRepository_ResolvesViaWorkflowContext()
{
// Regression test: when a dot-slash (./) composite action contains a
// nested $/actions/child step, LoadAction re-parses the action.yml at
// runtime and must resolve the $/ ref. The parent is repositoryType "self"
// so its Name and Ref are null — resolution must fall back to
// WorkflowRepository/WorkflowSha from the job context. Before the fix,
// this path hit a NullReferenceException at repoAction.Name.Replace().
try
{
// Arrange
Setup();
const string WorkflowRepo = "my-org/my-repo";
const string WorkflowSha = "abc123def456";
_ec.Object.Global.Variables.Set(Constants.Runner.Features.SelfRepository, "true");
var jobContext = new JobContext();
jobContext.WorkflowRepository = WorkflowRepo;
jobContext.WorkflowSha = WorkflowSha;
_ec.Setup(x => x.JobContext).Returns(jobContext);
// Stage the dot-slash composite in the workspace directory.
// It contains a nested $/actions/child step.
string workspaceDir = Path.Combine(_workFolder, "actions", "actions");
string compositeDir = Path.Combine(workspaceDir, "my-composite");
Directory.CreateDirectory(compositeDir);
File.WriteAllText(Path.Combine(compositeDir, Constants.Path.ActionManifestYmlFile), @"
name: 'DotSlash Parent'
description: 'Composite loaded via ./ that nests a $/ ref'
runs:
using: 'composite'
steps:
- run: echo 'hello'
shell: bash
- uses: $/actions/child
");
// Stage the child action in the actions cache under the workflow repo.
string actionsDir = Path.Combine(_workFolder, Constants.Path.ActionsDirectory);
string childDir = Path.Combine(actionsDir, WorkflowRepo.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), WorkflowSha, "actions", "child");
Directory.CreateDirectory(childDir);
File.WriteAllText(Path.Combine(childDir, Constants.Path.ActionManifestYmlFile), @"
name: 'Child'
description: 'Leaf action'
runs:
using: 'node20'
main: 'index.js'
");
// Create dot-slash step with Name = null (the real scenario).
var instance = new Pipelines.ActionStep()
{
Id = Guid.NewGuid(),
Reference = new Pipelines.RepositoryPathReference()
{
Name = null,
Ref = null,
RepositoryType = Pipelines.PipelineConstants.SelfAlias,
Path = "my-composite"
}
};
// Act — should NOT throw NullReferenceException
Definition definition = _actionManager.LoadAction(_ec.Object, instance);
// Assert — loaded the composite successfully
Assert.NotNull(definition);
Assert.NotNull(definition.Data);
Assert.Equal(ActionExecutionType.Composite, definition.Data.Execution.ExecutionType);
// Assert — the nested $/ step was resolved to the workflow repo
var compositeData = definition.Data.Execution as CompositeActionExecutionData;
Assert.NotNull(compositeData);
var childStep = compositeData.Steps
.OfType<Pipelines.ActionStep>()
.FirstOrDefault(s => s.Reference is Pipelines.RepositoryPathReference r
&& r.Path == "actions/child");
Assert.NotNull(childStep);
var childRef = childStep.Reference as Pipelines.RepositoryPathReference;
Assert.Equal(Pipelines.RepositoryTypes.GitHub, childRef.RepositoryType);
Assert.Equal(WorkflowRepo, childRef.Name);
Assert.Equal(WorkflowSha, childRef.Ref);
}
finally
{
Teardown();
}
}
}
}