Compare commits

...

10 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
Lokesh Gopu
16c52e389d Canceled background steps should not impact job result (#4482) 2026-06-08 17:18:11 -04:00
Francesco Renzi
060eeda6e0 Preparing runner release 2.335.0 (#4481)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 17:57:10 +01:00
23 changed files with 1170 additions and 86 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

@@ -1,36 +1,40 @@
## What's Changed
* Bump flatted from 3.2.7 to 3.4.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4307
* Add DAP server by @rentziass in https://github.com/actions/runner/pull/4298
* Bump @typescript-eslint/eslint-plugin from 8.57.1 to 8.57.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4310
* Remove AllowCaseFunction feature flag by @ericsciple in https://github.com/actions/runner/pull/4316
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4319
* Batch and deduplicate action resolution across composite depths by @stefanpenner in https://github.com/actions/runner/pull/4296
* Add support for Bearer token in action archive downloads by @TingluoHuang in https://github.com/actions/runner/pull/4321
* Bump brace-expansion in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4318
* Add devtunnel connection for debugger jobs by @rentziass in https://github.com/actions/runner/pull/4317
* Update Docker to v29.3.1 and Buildx to v0.33.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4324
* Bump @typescript-eslint/eslint-plugin from 8.57.2 to 8.58.1 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4327
* Bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/actions/runner/pull/4331
* Bump typescript from 5.9.3 to 6.0.2 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4329
* fix: only show changed versions in node upgrade PR description by @salmanmkc in https://github.com/actions/runner/pull/4332
* Bump System.Formats.Asn1, Cryptography.Pkcs, ProtectedData, ServiceController, CodePages, Threading.Channels, @actions/glob, @typescript-eslint/parser, lint-staged, picomatch by @Copilot in https://github.com/actions/runner/pull/4333
* feat: add `job.workflow_*` typed accessors to JobContext by @salmanmkc in https://github.com/actions/runner/pull/4335
* Add WS bridge over DAP TCP server by @rentziass in https://github.com/actions/runner/pull/4328
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4355
* Bump Docker version to 29.4.0 by @Copilot in https://github.com/actions/runner/pull/4352
* Update dotnet sdk to latest version @8.0.420 by @github-actions[bot] in https://github.com/actions/runner/pull/4356
* Bump @typescript-eslint/parser from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4360
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4362
* Add vulnerability-alerts permission by @salmanmkc in https://github.com/actions/runner/pull/4350
* Bump @typescript-eslint/eslint-plugin from 8.58.1 to 8.59.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4359
* Bump System.ServiceProcess.ServiceController from 10.0.3 to 10.0.6 by @dependabot[bot] in https://github.com/actions/runner/pull/4358
* Bump typescript from 6.0.2 to 6.0.3 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4353
* Bump Microsoft.DevTunnels.Connections from 1.3.16 to 1.3.39 by @dependabot[bot] in https://github.com/actions/runner/pull/4339
* Bump System.ServiceProcess.ServiceController from 10.0.6 to 10.0.7 by @dependabot[bot] in https://github.com/actions/runner/pull/4370
* Bump @actions/glob from 0.6.1 to 0.7.0 in /src/Misc/expressionFunc/hashFiles by @dependabot[bot] in https://github.com/actions/runner/pull/4367
* feat: propagate actions dependencies by @nodeselector in https://github.com/actions/runner/pull/4372
* Not retry and report action download 403. by @TingluoHuang in https://github.com/actions/runner/pull/4391
* Update setup job starting logs by @GitPaulo in https://github.com/actions/runner/pull/4383
* fix: expand commit hash regex to support SHA-256 (64-char) hashes by @yaananth in https://github.com/actions/runner/pull/4347
* Move dap setup to setup job step by @rentziass in https://github.com/actions/runner/pull/4403
* Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) by @dvaldivia in https://github.com/actions/runner/pull/4394
* Update dotnet sdk to latest version @8.0.421 by @github-actions[bot] in https://github.com/actions/runner/pull/4428
* Update Docker to v29.5.0 and Buildx to v0.34.0 by @github-actions[bot] in https://github.com/actions/runner/pull/4425
* Execute debugger REPL commands inside job container by @rentziass in https://github.com/actions/runner/pull/4420
* Send welcome message in debugger console on connect by @rentziass in https://github.com/actions/runner/pull/4419
* Update snapshot-if context and functions by @drielenr in https://github.com/actions/runner/pull/4443
* chore: update Node versions by @github-actions[bot] in https://github.com/actions/runner/pull/4452
* Allow disable node v8 maglev jit compiler on node24. by @TingluoHuang in https://github.com/actions/runner/pull/4447
* Update Node 24 default date to June 16th, 2026 by @salmanmkc in https://github.com/actions/runner/pull/4462
* Populate telemetry for non-action post-job steps by @drielenr in https://github.com/actions/runner/pull/4463
* Add SDK types and results plumbing for background step control by @lokesh755 in https://github.com/actions/runner/pull/4472
* Add job execution view model by @rentziass in https://github.com/actions/runner/pull/4470
* Add thread-safety locks to StepsContext by @lokesh755 in https://github.com/actions/runner/pull/4475
* Add background step deferral infrastructure and metadata plumbing by @lokesh755 in https://github.com/actions/runner/pull/4479
* Wire job execution view into DAP by @rentziass in https://github.com/actions/runner/pull/4471
* Background steps execution engine by @lokesh755 in https://github.com/actions/runner/pull/4476
* Update Docker to v29.5.2 and Buildx to v0.34.1 by @github-actions[bot] in https://github.com/actions/runner/pull/4451
* BrokerServer should not retry on 401. by @TingluoHuang in https://github.com/actions/runner/pull/4445
* Add new env var to allow single-prefix multiline logs on stdout by @nuclearpidgeon in https://github.com/actions/runner/pull/4424
* Bump Microsoft.DevTunnels.Connections from 1.3.39 to 1.3.48 by @dependabot[bot] in https://github.com/actions/runner/pull/4441
* Bump System.Formats.Asn1 and System.Security.Cryptography.Pkcs by @dependabot[bot] in https://github.com/actions/runner/pull/4369
## New Contributors
* @stefanpenner made their first contribution in https://github.com/actions/runner/pull/4296
* @GitPaulo made their first contribution in https://github.com/actions/runner/pull/4383
* @dvaldivia made their first contribution in https://github.com/actions/runner/pull/4394
* @drielenr made their first contribution in https://github.com/actions/runner/pull/4443
* @nuclearpidgeon made their first contribution in https://github.com/actions/runner/pull/4424
**Full Changelog**: https://github.com/actions/runner/compare/v2.333.1...v2.334.0
**Full Changelog**: https://github.com/actions/runner/compare/v2.334.0...v2.335.0
_Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet.
To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository.

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

@@ -32,6 +32,11 @@ namespace GitHub.Runner.Worker
// IDs of background steps that have already been completed (waited on or canceled).
// Used to avoid waiting on or flushing the same step more than once.
private readonly HashSet<string> _completedStepIds = new();
// IDs of background steps that were explicitly canceled via a `cancel` control step.
// These steps are expected to be canceled, so their (Canceled) result must not be
// merged into the overall job result.
private readonly HashSet<string> _explicitlyCanceledStepIds = new();
private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim(DefaultMaxBackgroundSteps);
/// <summary>
@@ -41,6 +46,7 @@ namespace GitHub.Runner.Worker
{
_backgroundSteps.Clear();
_completedStepIds.Clear();
_explicitlyCanceledStepIds.Clear();
var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps;
_backgroundSlotSemaphore = new SemaphoreSlim(max);
}
@@ -85,6 +91,9 @@ namespace GitHub.Runner.Worker
// Safety net
// -----------------------------------------------------------------
// Drain any background steps that weren't already waited on by an explicit wait/cancel
// control step, then merge the final results of all background steps into a single result
// for the caller to fold into the job result.
public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancellationToken)
{
var unwaitedIds = _backgroundSteps.Keys.Where(id => !_completedStepIds.Contains(id)).ToList();
@@ -95,14 +104,26 @@ namespace GitHub.Runner.Worker
CompleteWaitedSteps(unwaitedIds);
}
// Report the merged result of all background steps; the caller merges this into the job result.
var result = TaskResult.Succeeded;
foreach (var (_, (step, _, _)) in _backgroundSteps)
foreach (var (stepId, (step, _, _)) in _backgroundSteps)
{
if (step.ExecutionContext.Result.HasValue)
// A step that succeeded does not set a Result by default, so a missing
// value means the step succeeded and there is nothing to merge.
if (!step.ExecutionContext.Result.HasValue)
{
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
continue;
}
// A step explicitly canceled via a `cancel` control step is expected to be canceled,
// so a Canceled result must not influence the overall job result. However, if the step
// failed (e.g. before the cancellation took effect), that failure should still count.
if (_explicitlyCanceledStepIds.Contains(stepId) &&
step.ExecutionContext.Result.Value == TaskResult.Canceled)
{
continue;
}
result = TaskResultUtil.MergeTaskResults(result, step.ExecutionContext.Result.Value);
}
if (result != TaskResult.Succeeded)
@@ -256,6 +277,13 @@ namespace GitHub.Runner.Worker
return;
}
// Mark these steps as expected-to-be-canceled so their result does not
// affect the overall job result.
foreach (var id in cancelStepIds)
{
_explicitlyCanceledStepIds.Add(id);
}
var idsToCancel = cancelStepIds
.Where(id => _backgroundSteps.ContainsKey(id) && !_backgroundSteps[id].Task.IsCompleted)
.ToArray();

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();
}
}
}
}

View File

@@ -372,6 +372,88 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task CanceledBackgroundStepDoesNotAffectJobResult()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: a background step that runs until explicitly canceled. When canceled it
// reports TaskResult.Canceled, but since the cancellation is expected (driven by a
// cancel control step), it must not impact the overall job result.
using var stepCts = new CancellationTokenSource();
var bgStep = CreateStep(hc, TaskResult.Succeeded, "success()", name: "server", contextName: "server", isBackground: true);
var bgStepContext = Mock.Get(bgStep.Object.ExecutionContext);
bgStepContext.Setup(x => x.CancellationToken).Returns(stepCts.Token);
bgStepContext.Setup(x => x.CancelToken()).Callback(() => stepCts.Cancel());
bgStep.Setup(x => x.RunAsync()).Returns(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2), stepCts.Token);
});
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: the canceled background step reported Canceled, but the job result is unaffected.
Assert.Equal(TaskResult.Canceled, bgStep.Object.ExecutionContext.Result);
Assert.Equal(TaskResult.Succeeded, _ec.Object.Result ?? TaskResult.Succeeded);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task FailedBackgroundStepTargetedByCancelStillAffectsJobResult()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: a background step that fails (e.g. before the cancel takes effect). Even
// though a cancel control step targets it, its Failed result must still propagate to
// the overall job result.
var bgStep = CreateStep(hc, TaskResult.Failed, "success()", name: "server", contextName: "server", isBackground: true);
bgStep.Setup(x => x.RunAsync()).Returns(Task.CompletedTask);
bgStep.Setup(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = "server",
Id = Guid.NewGuid(),
ContextName = "server",
Background = true,
});
var cancelStep = CreateCancelStep(hc, "server");
_ec.Object.Result = null;
_ec.Setup(x => x.JobSteps).Returns(new Queue<IStep>(new IStep[]
{
bgStep.Object, cancelStep
}));
// Act
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert: the background step failed, so the job result reflects that failure.
Assert.Equal(TaskResult.Failed, bgStep.Object.ExecutionContext.Result);
Assert.Equal(TaskResult.Failed, _ec.Object.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -1 +1 @@
2.334.0
2.335.0