Compare commits

...

13 Commits

Author SHA1 Message Date
Brian DeHamer
35e45850b5 Add support for $GITHUB_ARTIFACTS environment files (#4527)
Signed-off-by: Brian DeHamer <bdehamer@github.com>
Co-authored-by: Luke Tomlinson <luketomlinson@github.com>
2026-07-07 16:29:00 -04:00
Stefan Kuhn
5c45757098 Link config.sh and installdependencies.sh in docs (#4526) 2026-07-06 22:30:15 -04:00
github-actions[bot]
a0a67d3939 Update dotnet sdk to latest version @8.0.422 (#4504)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 22:01:29 -04:00
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
38 changed files with 2687 additions and 91 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.421"
"version": "8.0.422"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

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

@@ -8,7 +8,7 @@ Please see "[Supported architectures and operating systems for self-hosted runne
## Install .Net Core 3.x Linux Dependencies
The `./config.sh` will check .Net Core 3.x dependencies during runner configuration.
The [config.sh](../../src/Misc/layoutroot/config.sh) will check .Net Core 3.x dependencies during runner configuration.
You might see something like this which indicate a dependency's missing.
```bash
./config.sh
@@ -17,7 +17,7 @@ You might see something like this which indicate a dependency's missing.
Dependencies is missing for Dotnet Core 6.0
Execute ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.
```
You can easily correct the problem by executing `./bin/installdependencies.sh`.
You can easily correct the problem by executing [installdependencies.sh](../../src/Misc/layoutbin/installdependencies.sh).
The `installdependencies.sh` script should install all required dependencies on all supported Linux versions
> Note: The `installdependencies.sh` script will try to use the default package management mechanism on your Linux flavor (ex. `yum`/`apt-get`/`apt`).

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,8 @@ 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 AllowArtifactsFile = "actions_runner_allow_artifacts_file";
public static readonly string SelfRepository = "actions_self_repository";
}
// Node version migration related constants
@@ -227,6 +229,12 @@ namespace GitHub.Runner.Common
public static readonly string UnsupportedStopCommandTokenDisabled = "You cannot use a endToken that is an empty string, the string 'pause-logging', or another workflow command. For more information see: https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#example-stopping-and-starting-workflow-commands or opt into insecure command execution by setting the `ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS` environment variable to `true`.";
public static readonly string UnsupportedSummarySize = "$GITHUB_STEP_SUMMARY upload aborted, supports content up to a size of {0}k, got {1}k. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
public static readonly string SummaryUploadError = "$GITHUB_STEP_SUMMARY upload aborted, an error occurred when uploading the summary. For more information see: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary";
// $GITHUB_ARTIFACTS file command
public static readonly string ArtifactsFileSizeExceeded = "$GITHUB_ARTIFACTS file exceeds the maximum size of {0} KiB (got {1} KiB).";
public static readonly string ArtifactsAggregateLimitExceeded = "The job has exceeded the maximum of {0} declared artifacts.";
public static readonly string ArtifactsInvalidLine = "Invalid $GITHUB_ARTIFACTS entry on line {0}: {1}";
public static readonly string ArtifactsConflictingDigest = "Conflicting digest for artifact '{0}': previously declared as '{1}', now declared as '{2}'.";
}
public static class RunnerEvent

View File

@@ -62,6 +62,8 @@ namespace GitHub.Runner.Common
Add<T>(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker");
Add<T>(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker");
Add<T>(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker");
Add<T>(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker");
Add<T>(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker");
break;
case "GitHub.Runner.Listener.Check.ICheckExtension":
Add<T>(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener");

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

@@ -0,0 +1,52 @@
using System;
namespace GitHub.Runner.Worker
{
public enum ArtifactSubjectKind
{
File,
OciSubject,
}
/// <summary>
/// Represents a single artifact subject declared via the
/// <c>GITHUB_ARTIFACTS</c> per-step environment file.
/// </summary>
public sealed class ArtifactSubject : IEquatable<ArtifactSubject>
{
public ArtifactSubject(string name, string digest, ArtifactSubjectKind kind)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException("Name must not be null or empty.", nameof(name));
}
if (string.IsNullOrEmpty(digest))
{
throw new ArgumentException("Digest must not be null or empty.", nameof(digest));
}
Name = name;
Digest = digest;
Kind = kind;
}
public string Name { get; }
public string Digest { get; }
public ArtifactSubjectKind Kind { get; }
public bool Equals(ArtifactSubject other)
{
if (other is null)
{
return false;
}
return string.Equals(Name, other.Name, StringComparison.Ordinal)
&& string.Equals(Digest, other.Digest, StringComparison.Ordinal);
}
public override bool Equals(object obj) => Equals(obj as ArtifactSubject);
public override int GetHashCode() => HashCode.Combine(Name, Digest);
public override string ToString() => $"{Name}@{Digest}";
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace GitHub.Runner.Worker
{
/// <summary>
/// File command extension that exposes the job-scoped aggregate of
/// <see cref="GlobalContext.ArtifactSubjects"/> as a read-only JSON
/// file. Subsequent steps in the same job read the file via the
/// <c>GITHUB_ARTIFACTS_LIST</c> environment variable, getting a
/// running view of every artifact declared via
/// <c>$GITHUB_ARTIFACTS</c> in earlier steps.
/// </summary>
/// <remarks>
/// The file uses the existing per-step file-command lifecycle:
/// <see cref="FileCommandManager.InitializeFiles"/> creates a fresh
/// file, invokes <see cref="PopulateInitialContents"/> here, exposes
/// the (translated) path to the step's environment, and (for the
/// read-only file) ignores anything the step writes back.
///
/// The file is always written when the feature is enabled, so
/// consumers never need to branch on "did the runner inject this?".
/// An empty aggregate produces <c>{"version":1,"subjects":[]}</c>.
/// </remarks>
public sealed class ArtifactsListFileCommand : RunnerService, IFileCommandExtension
{
public const int FormatVersion = 1;
public string ContextName => "artifacts_list";
public string FilePrefix => "artifacts_list_";
public Type ExtensionType => typeof(IFileCommandExtension);
public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
{
ArgUtil.NotNull(context, nameof(context));
// Feature flag gate. Mirrors CreateArtifactsFileCommand so the
// write side and the read side are toggled together.
var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar));
if (!enabled)
{
Trace.Verbose("$GITHUB_ARTIFACTS_LIST publishing is disabled (feature flag and env-var fallback are both off).");
return;
}
var aggregate = context.Global.ArtifactSubjects
?? new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal);
var subjects = new JArray();
// Emit subjects sorted by name so the output is deterministic
// regardless of the backing dictionary's enumeration order
// (which is not contractually guaranteed).
foreach (var entry in aggregate.Values.OrderBy(v => v.Name, StringComparer.Ordinal))
{
subjects.Add(new JObject
{
["name"] = entry.Name,
["digest"] = entry.Digest,
["kind"] = entry.Kind == ArtifactSubjectKind.OciSubject ? "oci" : "file",
});
}
var payload = new JObject
{
["version"] = FormatVersion,
["subjects"] = subjects,
};
// UTF-8 without BOM; consumers in other languages should not
// have to special-case a leading BOM.
File.WriteAllText(filePath, payload.ToString(Formatting.None), new UTF8Encoding(false));
Trace.Info($"Wrote $GITHUB_ARTIFACTS_LIST with {aggregate.Count} subject(s) to '{filePath}'");
}
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
{
// Read-only file: anything the step writes here is ignored.
// The aggregate is fed only by the write-side $GITHUB_ARTIFACTS
// file processed by CreateArtifactsFileCommand.
}
}
}

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

@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
namespace GitHub.Runner.Worker
{
/// <summary>
/// File command extension that implements the <c>GITHUB_ARTIFACTS</c>
/// per-step environment file contract.
/// </summary>
/// <remarks>
/// Lifecycle is identical to the other per-step file commands:
/// <see cref="FileCommandManager"/> creates an empty file before each
/// step runs and invokes <see cref="ProcessCommand"/> after the step
/// completes. This class is responsible for parsing the file's
/// contents, validating each entry, and aggregating the resulting
/// (name, digest) pairs onto <see cref="GlobalContext.ArtifactSubjects"/>
/// at job scope.
///
/// The feature is gated by the <c>actions_runner_allow_artifacts_file</c>
/// feature flag. When the flag is disabled, the env var is still
/// exposed but writes are silently ignored.
/// </remarks>
public sealed class CreateArtifactsFileCommand : RunnerService, IFileCommandExtension
{
// Each per-step file may contain at most 1 MiB.
public const int MaxFileSizeBytes = 1024 * 1024;
// A job may declare at most 500 artifacts in aggregate.
public const int MaxAggregateArtifacts = 500;
public string ContextName => "artifacts";
public string FilePrefix => "artifacts_";
// Runner-side environment variable that enables the feature on
// self-hosted runners where the server-side feature flag is not
// configurable. Mirrors patterns like
// ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER elsewhere in the runner.
public const string EnableEnvVar = "ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE";
public Type ExtensionType => typeof(IFileCommandExtension);
// Recognized scheme prefixes (case-insensitive).
private const string FileScheme = "file://";
private const string OciScheme = "oci://";
// Matches "<scheme>://...". Used to detect unsupported URI schemes.
// Scheme grammar per RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
private static readonly Regex s_schemeRegex = new(
@"^[A-Za-z][A-Za-z0-9+.\-]*://",
RegexOptions.Compiled);
// Matches "<ref>@<algo>:<hex>" where <algo> is sha256/sha384/sha512.
// Hex length is validated separately so we can produce a precise error.
private static readonly Regex s_ociDigestSuffixRegex = new(
@"^(?<ref>.+)@(?<algo>sha(?:256|384|512)):(?<hex>[0-9a-fA-F]+)$",
RegexOptions.Compiled);
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
{
ArgUtil.NotNull(context, nameof(context));
// Feature flag gate. Enabled when either the server-side
// feature flag is set, or the runner is started with the
// ACTIONS_RUNNER_ALLOW_ARTIFACTS_FILE env var set to true
// (the env-var fallback exists so self-hosted runners can
// opt in locally). Silently no-op when disabled.
var enabled = (context.Global.Variables.GetBoolean(Constants.Runner.Features.AllowArtifactsFile) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(EnableEnvVar));
if (!enabled)
{
Trace.Verbose("$GITHUB_ARTIFACTS processing is disabled (feature flag and env-var fallback are both off).");
return;
}
Trace.Info($"Processing $GITHUB_ARTIFACTS file '{filePath}'");
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
{
Trace.Info("$GITHUB_ARTIFACTS file does not exist; nothing to process.");
return;
}
var fileSize = new FileInfo(filePath).Length;
if (fileSize == 0)
{
Trace.Info("$GITHUB_ARTIFACTS file is empty; nothing to process.");
return;
}
if (fileSize > MaxFileSizeBytes)
{
throw new Exception(StringUtil.Format(
Constants.Runner.ArtifactsFileSizeExceeded,
MaxFileSizeBytes / 1024,
fileSize / 1024));
}
// Per-step subjects parsed from this file; aggregated into the
// job-level set at the end so a single malformed line fails the
// step without partially polluting the aggregate.
var parsed = new List<(int LineNumber, ArtifactSubject Subject)>();
// Relative artifact paths are resolved against the workspace
// root (GITHUB_WORKSPACE), not the step's working directory.
// This matches the established runner precedent set by
// hashFiles() which always resolve relative paths against
// the workspace root regardless of any step-level
// `working-directory:`.
var workspaceRoot = ResolveWorkspaceRoot(context);
var lines = File.ReadAllLines(filePath, Encoding.UTF8);
for (var i = 0; i < lines.Length; i++)
{
var lineNumber = i + 1;
var raw = lines[i];
var trimmed = raw.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (trimmed[0] == '#')
{
continue;
}
ArtifactSubject subject;
try
{
subject = ParseLine(trimmed, workspaceRoot, container);
}
catch (ArtifactsParseException ex)
{
throw new Exception(StringUtil.Format(
Constants.Runner.ArtifactsInvalidLine,
lineNumber,
ex.Message));
}
parsed.Add((lineNumber, subject));
}
// Aggregate at job scope: dedup identical, reject conflicts,
// enforce the 500-artifact cap (after dedup so identical
// duplicates above the cap do not fail).
var aggregate = context.Global.ArtifactSubjects;
if (aggregate == null)
{
throw new InvalidOperationException("Global.ArtifactSubjects is not initialized.");
}
var addedThisStep = 0;
foreach (var (lineNumber, subject) in parsed)
{
if (aggregate.TryGetValue(subject.Name, out var existing))
{
if (string.Equals(existing.Digest, subject.Digest, StringComparison.Ordinal))
{
// Identical declaration — silently deduplicate.
Trace.Info($"Skipped duplicate artifact subject '{subject.Name}' (digest={subject.Digest})");
continue;
}
throw new Exception(StringUtil.Format(
Constants.Runner.ArtifactsInvalidLine,
lineNumber,
StringUtil.Format(
Constants.Runner.ArtifactsConflictingDigest,
subject.Name,
existing.Digest,
subject.Digest)));
}
if (aggregate.Count >= MaxAggregateArtifacts)
{
throw new Exception(StringUtil.Format(
Constants.Runner.ArtifactsInvalidLine,
lineNumber,
StringUtil.Format(
Constants.Runner.ArtifactsAggregateLimitExceeded,
MaxAggregateArtifacts)));
}
aggregate[subject.Name] = subject;
addedThisStep++;
Trace.Info($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
context.Debug($"Declared artifact subject '{subject.Name}' (kind={subject.Kind}, digest={subject.Digest})");
}
if (addedThisStep > 0)
{
// Mirror the existing file-command UX: a single, terse
// user-visible line that confirms the declarations landed.
context.Output($"Captured {addedThisStep} artifact subject(s) from this step (job total: {aggregate.Count}).");
}
}
private ArtifactSubject ParseLine(string trimmed, string workspaceRoot, ContainerInfo container)
{
// Reject lines containing '=' — reserved for a future v2
// key/value extension to the format.
if (trimmed.IndexOf('=') >= 0)
{
throw new ArtifactsParseException("entries containing '=' are reserved and not permitted");
}
// Handle the explicit escape-hatch schemes first
// (case-insensitive).
if (StartsWithIgnoreCase(trimmed, FileScheme))
{
var path = trimmed.Substring(FileScheme.Length);
if (string.IsNullOrWhiteSpace(path))
{
throw new ArtifactsParseException("file:// entries must include a path");
}
return MakeFileSubject(path, workspaceRoot, container);
}
if (StartsWithIgnoreCase(trimmed, OciScheme))
{
var rest = trimmed.Substring(OciScheme.Length);
var match = s_ociDigestSuffixRegex.Match(rest);
if (!match.Success)
{
throw new ArtifactsParseException("oci:// entries must include an @sha{256,384,512}:<hex> digest");
}
return MakeOciSubject(match);
}
// Reject any other URI scheme up-front.
if (s_schemeRegex.IsMatch(trimmed))
{
throw new ArtifactsParseException("unsupported URI scheme");
}
// Otherwise discriminate syntactically: an entry that matches
// the OCI digest suffix shape (with the right hex length for
// its algorithm) is an OCI subject; everything else is a path.
var ociMatch = s_ociDigestSuffixRegex.Match(trimmed);
if (ociMatch.Success && IsExpectedHexLength(ociMatch.Groups["algo"].Value, ociMatch.Groups["hex"].Value))
{
return MakeOciSubject(ociMatch);
}
return MakeFileSubject(trimmed, workspaceRoot, container);
}
private static ArtifactSubject MakeOciSubject(Match match)
{
var refName = match.Groups["ref"].Value;
var algo = match.Groups["algo"].Value.ToLowerInvariant();
var hex = match.Groups["hex"].Value.ToLowerInvariant();
if (!IsExpectedHexLength(algo, hex))
{
throw new ArtifactsParseException(
$"digest '{algo}' must be {ExpectedHexLength(algo)} hex characters, got {hex.Length}");
}
if (string.IsNullOrEmpty(refName))
{
throw new ArtifactsParseException("oci subject must include a reference");
}
return new ArtifactSubject(refName, $"{algo}:{hex}", ArtifactSubjectKind.OciSubject);
}
private static ArtifactSubject MakeFileSubject(string declaredPath, string workspaceRoot, ContainerInfo container)
{
var hostPath = ResolveFilePath(declaredPath, workspaceRoot, container);
if (!File.Exists(hostPath))
{
if (Directory.Exists(hostPath))
{
throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
}
// For relative paths, surface where we looked so authors
// aren't surprised that resolution is workspace-relative.
if (!Path.IsPathRooted(declaredPath))
{
throw new ArtifactsParseException(
$"file '{declaredPath}' does not exist (relative paths are resolved against the workspace root '{workspaceRoot}')");
}
throw new ArtifactsParseException($"file '{declaredPath}' does not exist");
}
// FileInfo + File.GetAttributes guards against named pipes,
// device files, etc. We accept regular files and symlinks
// resolved to regular files.
var attrs = File.GetAttributes(hostPath);
if ((attrs & FileAttributes.Directory) == FileAttributes.Directory)
{
throw new ArtifactsParseException($"'{declaredPath}' is a directory, not a regular file");
}
string hex;
using (var stream = File.OpenRead(hostPath))
using (var sha = SHA256.Create())
{
var hash = sha.ComputeHash(stream);
var sb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
{
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
hex = sb.ToString();
}
var name = Path.GetFileName(hostPath);
return new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.File);
}
private static string ResolveFilePath(string declaredPath, string workspaceRoot, ContainerInfo container)
{
if (Path.IsPathRooted(declaredPath))
{
if (container == null)
{
return declaredPath;
}
// Absolute path from a container step: it lives in the
// container's filesystem namespace, so translate it to the
// host path via the container's volume mounts.
// TranslateToHostPath returns the input unchanged when the
// path is not under any mount. We must NOT fall back to the
// host file at that same path -- that would hash an arbitrary
// host file the container step never referenced -- so reject
// it instead.
var hostPath = container.TranslateToHostPath(declaredPath);
if (string.Equals(hostPath, declaredPath, StringComparison.Ordinal))
{
throw new ArtifactsParseException(
$"absolute path '{declaredPath}' is not inside a volume mounted into the container and cannot be resolved");
}
return hostPath;
}
// Relative path: resolve against the workspace root
// (GITHUB_WORKSPACE).
var baseDir = workspaceRoot ?? string.Empty;
return Path.GetFullPath(Path.Combine(baseDir, declaredPath));
}
private static string ResolveWorkspaceRoot(IExecutionContext context)
{
// The workspace root (GITHUB_WORKSPACE) is the resolution base
// for all relative artifact paths.
var workspace = context.GetGitHubContext("workspace");
return string.IsNullOrEmpty(workspace) ? null : workspace;
}
private static bool StartsWithIgnoreCase(string s, string prefix)
{
return s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
private static int ExpectedHexLength(string algo)
{
return algo.ToLowerInvariant() switch
{
"sha256" => 64,
"sha384" => 96,
"sha512" => 128,
_ => -1,
};
}
private static bool IsExpectedHexLength(string algo, string hex)
{
var expected = ExpectedHexLength(algo);
return expected > 0 && hex.Length == expected;
}
private sealed class ArtifactsParseException : Exception
{
public ArtifactsParseException(string message) : base(message) { }
}
}
}

View File

@@ -973,6 +973,9 @@ namespace GitHub.Runner.Worker
// Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation)
Global.Arm32Node20Actions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
Global.ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal);
// Job Outputs
JobOutputs = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);

View File

@@ -55,6 +55,18 @@ namespace GitHub.Runner.Worker
TryDeleteFile(newPath);
File.Create(newPath).Dispose();
// Give extensions a chance to populate the file before
// the step starts (e.g., read-only views of job state).
// Errors are logged but must not fail step setup.
try
{
fileCommand.PopulateInitialContents(context, newPath, container);
}
catch (Exception ex)
{
_trace.Warning($"Failed to populate initial contents for file command '{fileCommand.ContextName}': {ex}");
}
var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath;
context.SetGitHubContext(fileCommand.ContextName, pathToSet);
}
@@ -102,6 +114,14 @@ namespace GitHub.Runner.Worker
string FilePrefix { get; }
void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container);
// Optional hook invoked by FileCommandManager.InitializeFiles
// after creating the empty per-step file. Extensions that need to
// pre-populate the file (e.g., a read-only view of job-scoped
// state) override this; the default no-op preserves the existing
// "empty file at start of step" behavior for write-only file
// commands such as GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, etc.
void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container) { }
}
public sealed class AddPathFileCommand : RunnerService, IFileCommandExtension

View File

@@ -15,6 +15,8 @@ namespace GitHub.Runner.Worker
"actor",
"actor_id",
"api_url",
"artifacts",
"artifacts_list",
"base_ref",
"env",
"event_name",

View File

@@ -39,5 +39,9 @@ namespace GitHub.Runner.Worker
public HashSet<string> UpgradedToNode24Actions { get; set; }
public HashSet<string> Arm32Node20Actions { get; set; }
public IList<String> ActionsDependencies { get; set; }
// Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS.
// Keyed by canonical subject name (OCI ref without digest, or file basename).
public IDictionary<string, ArtifactSubject> ArtifactSubjects { get; set; }
}
}

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

@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class ArtifactsListFileCommandL0
{
private Mock<IExecutionContext> _executionContext;
private string _rootDirectory;
private string _outputFile;
private ArtifactsListFileCommand _command;
private GlobalContext _global;
private ITraceWriter _trace;
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmptyAggregate_WritesVersionedJsonWithEmptySubjects()
{
using (var hostContext = Setup())
{
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var json = JObject.Parse(File.ReadAllText(_outputFile));
Assert.Equal(ArtifactsListFileCommand.FormatVersion, json["version"].Value<int>());
Assert.Empty((JArray)json["subjects"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SingleSubject_SerializedCorrectly()
{
using (var hostContext = Setup())
{
_global.ArtifactSubjects["myapp"] = new ArtifactSubject(
"myapp",
"sha256:" + new string('a', 64),
ArtifactSubjectKind.File);
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var json = JObject.Parse(File.ReadAllText(_outputFile));
var subjects = (JArray)json["subjects"];
Assert.Single(subjects);
Assert.Equal("myapp", subjects[0]["name"].Value<string>());
Assert.Equal("sha256:" + new string('a', 64), subjects[0]["digest"].Value<string>());
Assert.Equal("file", subjects[0]["kind"].Value<string>());
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_KindIsLowercaseOci()
{
using (var hostContext = Setup())
{
_global.ArtifactSubjects["ghcr.io/x:1"] = new ArtifactSubject(
"ghcr.io/x:1",
"sha256:" + new string('b', 64),
ArtifactSubjectKind.OciSubject);
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var json = JObject.Parse(File.ReadAllText(_outputFile));
Assert.Equal("oci", json["subjects"][0]["kind"].Value<string>());
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MultipleSubjects_SortedByName()
{
using (var hostContext = Setup())
{
// Insert deliberately out of alphabetical order to prove the
// output is sorted by name rather than by insertion order.
_global.ArtifactSubjects["two"] = new ArtifactSubject("two", "sha256:" + new string('2', 64), ArtifactSubjectKind.File);
_global.ArtifactSubjects["one"] = new ArtifactSubject("one", "sha256:" + new string('1', 64), ArtifactSubjectKind.File);
_global.ArtifactSubjects["three"] = new ArtifactSubject("three", "sha256:" + new string('3', 64), ArtifactSubjectKind.OciSubject);
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var subjects = (JArray)JObject.Parse(File.ReadAllText(_outputFile))["subjects"];
Assert.Equal(3, subjects.Count);
Assert.Equal("one", subjects[0]["name"].Value<string>());
Assert.Equal("three", subjects[1]["name"].Value<string>());
Assert.Equal("two", subjects[2]["name"].Value<string>());
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FeatureFlagOff_LeavesFileEmpty()
{
using (var hostContext = Setup(featureFlag: false))
{
_global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
Assert.Equal(string.Empty, File.ReadAllText(_outputFile));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EnvVarFallback_EnablesPublishing()
{
using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
{
_global.ArtifactSubjects["myapp"] = new ArtifactSubject("myapp", "sha256:" + new string('a', 64), ArtifactSubjectKind.File);
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var json = JObject.Parse(File.ReadAllText(_outputFile));
Assert.Single((JArray)json["subjects"]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OutputFileIsUtf8WithoutBom()
{
using (var hostContext = Setup())
{
_command.PopulateInitialContents(_executionContext.Object, _outputFile, null);
var bytes = File.ReadAllBytes(_outputFile);
// UTF-8 BOM is EF BB BF
Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF,
"File should not begin with a UTF-8 BOM.");
// Sanity check that the file is valid JSON.
Assert.NotNull(JObject.Parse(System.Text.Encoding.UTF8.GetString(bytes)));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void ProcessCommand_IsNoOp()
{
using (var hostContext = Setup())
{
// Even if the step writes garbage, ProcessCommand must not touch the aggregate.
File.WriteAllText(_outputFile, "anything the step wrote");
_command.ProcessCommand(_executionContext.Object, _outputFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
{
// Reset env-var state across test runs in the same process.
Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
var hostContext = new TestHostContext(this, name);
_trace = hostContext.GetTrace();
var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
Directory.CreateDirectory(workDirectory);
_rootDirectory = Path.Combine(workDirectory, nameof(ArtifactsListFileCommandL0), name);
if (Directory.Exists(_rootDirectory))
{
Directory.Delete(_rootDirectory, recursive: true);
}
Directory.CreateDirectory(_rootDirectory);
_outputFile = Path.Combine(_rootDirectory, "artifacts_list");
File.WriteAllText(_outputFile, string.Empty);
var variableValues = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
if (featureFlag)
{
variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue("true");
}
var variables = new Variables(hostContext, variableValues);
_global = new GlobalContext
{
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
Variables = variables,
WriteDebug = true,
ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal),
};
_executionContext = new Mock<IExecutionContext>();
_executionContext.Setup(x => x.Global).Returns(_global);
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>
{
_trace.Info($"{tag}{message}");
});
_command = new ArtifactsListFileCommand();
_command.Initialize(hostContext);
return hostContext;
}
}
}

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

@@ -0,0 +1,627 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using Moq;
using Xunit;
using DTWebApi = GitHub.DistributedTask.WebApi;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class CreateArtifactsFileCommandL0
{
private const string FlagOn = "true";
private Mock<IExecutionContext> _executionContext;
private List<DTWebApi.Issue> _issues;
private string _rootDirectory;
private string _workspaceDirectory;
private CreateArtifactsFileCommand _command;
private GlobalContext _global;
private ITraceWriter _trace;
// ---------- Feature flag ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FeatureFlagOff_NoOp()
{
using (var hostContext = Setup(featureFlag: false))
{
var artifactsFile = WriteArtifactsFile("ghcr.io/octocat/myapp:1.0@sha256:" + new string('a', 64));
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EnvVarOverride_EnablesFeature()
{
using (var hostContext = Setup(featureFlag: false, envVarOverride: "true"))
{
var hex = new string('a', 64);
var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EnvVarFalse_DoesNotEnable()
{
using (var hostContext = Setup(featureFlag: false, envVarOverride: "false"))
{
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 64));
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
// ---------- Trivial cases ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileMissing_NoOp()
{
using (var hostContext = Setup())
{
var artifactsFile = Path.Combine(_rootDirectory, "does-not-exist");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmptyFile_NoOp()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile(string.Empty);
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void BlankAndCommentLines_Skipped()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile(
"",
"# this is a comment",
" # leading-whitespace comment",
"",
" ");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Empty(_global.ArtifactSubjects);
}
}
// ---------- OCI subjects ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_Sha256_HappyPath()
{
using (var hostContext = Setup())
{
var hex = new string('a', 64);
var artifactsFile = WriteArtifactsFile($"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
var subject = _global.ArtifactSubjects["ghcr.io/octocat/myapp:1.0.0"];
Assert.Equal($"sha256:{hex}", subject.Digest);
Assert.Equal(ArtifactSubjectKind.OciSubject, subject.Kind);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_Sha384_HappyPath()
{
using (var hostContext = Setup())
{
var hex = new string('b', 96);
var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha384:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
Assert.Equal($"sha384:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_Sha512_HappyPath()
{
using (var hostContext = Setup())
{
var hex = new string('c', 128);
var artifactsFile = WriteArtifactsFile($"ghcr.io/x/y@sha512:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
Assert.Equal($"sha512:{hex}", _global.ArtifactSubjects["ghcr.io/x/y"].Digest);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_DigestLowercased()
{
using (var hostContext = Setup())
{
var hex = new string('A', 64);
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Equal($"sha256:{hex.ToLowerInvariant()}", _global.ArtifactSubjects["ghcr.io/x"].Digest);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_PreservesTagAndRegistryPort()
{
using (var hostContext = Setup())
{
var hex = new string('d', 64);
var artifactsFile = WriteArtifactsFile($"localhost:5000/repo/img:v1@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
Assert.True(_global.ArtifactSubjects.ContainsKey("localhost:5000/repo/img:v1"));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_WrongHexLength_Throws()
{
using (var hostContext = Setup())
{
// 63 hex chars instead of 64 → falls back to file path parse → file missing → throws
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('a', 63));
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciSubject_NonHexChars_TreatedAsFile_Throws()
{
using (var hostContext = Setup())
{
// Non-hex character: digest regex doesn't match → treated as file path → file missing
var artifactsFile = WriteArtifactsFile("ghcr.io/x@sha256:" + new string('z', 64));
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciScheme_RejectsWhenDigestMissing()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile("oci://ghcr.io/x:1.0");
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
Assert.Contains("digest", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void OciScheme_HappyPath()
{
using (var hostContext = Setup())
{
var hex = new string('e', 64);
var artifactsFile = WriteArtifactsFile($"OCI://ghcr.io/x:1.0@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/x:1.0"));
}
}
// ---------- File subjects ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_Absolute_HappyPath()
{
using (var hostContext = Setup())
{
var artifactPath = Path.Combine(_rootDirectory, "binary.bin");
File.WriteAllBytes(artifactPath, new byte[] { 1, 2, 3, 4 });
var artifactsFile = WriteArtifactsFile(artifactPath);
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
var subject = _global.ArtifactSubjects["binary.bin"];
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
// sha256("\x01\x02\x03\x04") = 9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a
Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_RelativeToWorkspace()
{
using (var hostContext = Setup())
{
Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp"), new byte[] { 9 });
var artifactsFile = WriteArtifactsFile("dist/myapp");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
Assert.True(_global.ArtifactSubjects.ContainsKey("myapp"));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileScheme_TreatsAsFilePathEvenIfLooksLikeOci()
{
using (var hostContext = Setup())
{
// File literally named "image@sha256:deadbeef..." — force file path via file:// prefix.
var quirkyName = "image@sha256:" + new string('f', 64);
var artifactPath = Path.Combine(_rootDirectory, quirkyName);
File.WriteAllBytes(artifactPath, new byte[] { 1 });
var artifactsFile = WriteArtifactsFile("file://" + artifactPath);
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
var subject = _global.ArtifactSubjects[quirkyName];
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_Missing_Throws()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile(Path.Combine(_rootDirectory, "does-not-exist"));
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
Assert.Contains("does not exist", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_Directory_Throws()
{
using (var hostContext = Setup())
{
var dir = Path.Combine(_rootDirectory, "a-directory");
Directory.CreateDirectory(dir);
var artifactsFile = WriteArtifactsFile(dir);
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
Assert.Contains("not a regular file", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_ContainerAbsolute_InMount_ResolvesToHostFile()
{
using (var hostContext = Setup())
{
// The host file lives under a directory that is mounted into
// the container. The step declares the file using its
// container-namespace path, which must translate back to the
// host file so the digest is computed over the right bytes.
var hostDirectory = Path.Combine(_rootDirectory, "mounted");
Directory.CreateDirectory(hostDirectory);
File.WriteAllBytes(Path.Combine(hostDirectory, "app.bin"), new byte[] { 1, 2, 3, 4 });
var container = new ContainerInfo();
var containerDirectory = "/container-workspace";
container.AddPathTranslateMapping(hostDirectory, containerDirectory);
var artifactsFile = WriteArtifactsFile(Path.Combine(containerDirectory, "app.bin"));
_command.ProcessCommand(_executionContext.Object, artifactsFile, container);
Assert.Single(_global.ArtifactSubjects);
var subject = _global.ArtifactSubjects["app.bin"];
Assert.Equal(ArtifactSubjectKind.File, subject.Kind);
// sha256("\x01\x02\x03\x04")
Assert.Equal("sha256:9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a", subject.Digest);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileSubject_ContainerAbsolute_OutsideMount_Throws()
{
using (var hostContext = Setup())
{
// An absolute path that does not resolve into any mounted
// volume must be rejected rather than silently hashing the
// host file that happens to live at that same path.
var container = new ContainerInfo();
container.AddPathTranslateMapping(Path.Combine(_rootDirectory, "mounted"), "/container-workspace");
var artifactsFile = WriteArtifactsFile(Path.Combine("/unmapped-container-dir", "secret.bin"));
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, container));
Assert.Contains("line 1", ex.Message);
Assert.Contains("not inside a volume mounted", ex.Message);
}
}
// ---------- Format / scheme rules ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EqualsSign_Rejected()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile("name=ghcr.io/x@sha256:" + new string('a', 64));
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
Assert.Contains("'='", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void UnsupportedScheme_Rejected()
{
using (var hostContext = Setup())
{
var artifactsFile = WriteArtifactsFile("https://example.com/artifact");
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 1", ex.Message);
Assert.Contains("unsupported URI scheme", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LineNumberInError_PointsAtRightLine()
{
using (var hostContext = Setup())
{
var hex = new string('a', 64);
var artifactsFile = WriteArtifactsFile(
"# comment",
$"ghcr.io/ok@sha256:{hex}",
"",
"name=bogus");
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("line 4", ex.Message);
}
}
// ---------- Size and aggregate limits ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void FileTooLarge_Throws()
{
using (var hostContext = Setup())
{
var artifactsFile = Path.Combine(_rootDirectory, "huge");
// Slightly larger than 1 MiB.
File.WriteAllBytes(artifactsFile, new byte[CreateArtifactsFileCommand.MaxFileSizeBytes + 1]);
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("$GITHUB_ARTIFACTS", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AggregateCap_AllowsDuplicatesAtCap()
{
using (var hostContext = Setup())
{
// Pre-fill the aggregate to exactly the cap.
var hex = new string('a', 64);
for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
{
var name = $"ghcr.io/x{i}";
_global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
}
// A new step redeclares one of the existing artifacts identically — should NOT throw.
var artifactsFile = WriteArtifactsFile($"ghcr.io/x0@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Equal(CreateArtifactsFileCommand.MaxAggregateArtifacts, _global.ArtifactSubjects.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AggregateCap_FailsOnFirstDistinctOverflow()
{
using (var hostContext = Setup())
{
var hex = new string('a', 64);
for (var i = 0; i < CreateArtifactsFileCommand.MaxAggregateArtifacts; i++)
{
var name = $"ghcr.io/x{i}";
_global.ArtifactSubjects[name] = new ArtifactSubject(name, $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
}
var artifactsFile = WriteArtifactsFile($"ghcr.io/new@sha256:{hex}");
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("500", ex.Message);
}
}
// ---------- Aggregation rules ----------
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Aggregation_DedupsIdentical()
{
using (var hostContext = Setup())
{
var hex = new string('a', 64);
_global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hex}", ArtifactSubjectKind.OciSubject);
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Single(_global.ArtifactSubjects);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Aggregation_FailsOnConflict()
{
using (var hostContext = Setup())
{
var hexA = new string('a', 64);
var hexB = new string('b', 64);
_global.ArtifactSubjects["ghcr.io/x"] = new ArtifactSubject("ghcr.io/x", $"sha256:{hexA}", ArtifactSubjectKind.OciSubject);
var artifactsFile = WriteArtifactsFile($"ghcr.io/x@sha256:{hexB}");
var ex = Assert.Throws<Exception>(() => _command.ProcessCommand(_executionContext.Object, artifactsFile, null));
Assert.Contains("Conflicting digest", ex.Message);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MultipleEntries_AllProcessed()
{
using (var hostContext = Setup())
{
Directory.CreateDirectory(Path.Combine(_workspaceDirectory, "dist"));
File.WriteAllBytes(Path.Combine(_workspaceDirectory, "dist", "myapp-linux-amd64"), new byte[] { 7 });
var hex = new string('a', 64);
var artifactsFile = WriteArtifactsFile(
"# Release binary",
"dist/myapp-linux-amd64",
"",
"# Published container image",
$"ghcr.io/octocat/myapp:1.0.0@sha256:{hex}");
_command.ProcessCommand(_executionContext.Object, artifactsFile, null);
Assert.Equal(2, _global.ArtifactSubjects.Count);
Assert.True(_global.ArtifactSubjects.ContainsKey("myapp-linux-amd64"));
Assert.True(_global.ArtifactSubjects.ContainsKey("ghcr.io/octocat/myapp:1.0.0"));
}
}
// ---------- Setup helpers ----------
private string WriteArtifactsFile(params string[] lines)
{
var path = Path.Combine(_rootDirectory, "artifacts");
File.WriteAllText(path, string.Join("\n", lines), new UTF8Encoding(false));
return path;
}
private TestHostContext Setup(bool featureFlag = true, string envVarOverride = null, [CallerMemberName] string name = "")
{
_issues = new List<DTWebApi.Issue>();
// Ensure no leaked state from prior tests in the same process.
Environment.SetEnvironmentVariable(CreateArtifactsFileCommand.EnableEnvVar, envVarOverride);
var hostContext = new TestHostContext(this, name);
_trace = hostContext.GetTrace();
var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
Directory.CreateDirectory(workDirectory);
_rootDirectory = Path.Combine(workDirectory, nameof(CreateArtifactsFileCommandL0), name);
if (Directory.Exists(_rootDirectory))
{
Directory.Delete(_rootDirectory, recursive: true);
}
Directory.CreateDirectory(_rootDirectory);
_workspaceDirectory = Path.Combine(_rootDirectory, "workspace");
Directory.CreateDirectory(_workspaceDirectory);
var variableValues = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
if (featureFlag)
{
variableValues[Common.Constants.Runner.Features.AllowArtifactsFile] = new VariableValue(FlagOn);
}
var variables = new Variables(hostContext, variableValues);
_global = new GlobalContext
{
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
Variables = variables,
WriteDebug = true,
ArtifactSubjects = new Dictionary<string, ArtifactSubject>(StringComparer.Ordinal),
};
_executionContext = new Mock<IExecutionContext>();
_executionContext.Setup(x => x.Global).Returns(_global);
_executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(_workspaceDirectory);
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(issue);
_trace.Info($"Issue '{issue.Type}': {issue.Message}");
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>
{
_trace.Info($"{tag}{message}");
});
_command = new CreateArtifactsFileCommand();
_command.Initialize(hostContext);
return hostContext;
}
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class FileCommandManagerL0
{
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeFiles_InvokesPopulateInitialContents_OncePerExtension()
{
using (var hostContext = Setup(out var executionContext, out var ext))
{
var manager = new FileCommandManager();
manager.Initialize(hostContext);
manager.InitializeFiles(executionContext, null);
Assert.Equal(1, ext.PopulateCallCount);
Assert.True(File.Exists(ext.LastPopulatedPath));
// A second invocation should populate again with the new
// per-step file (file path rotates between calls).
var firstPath = ext.LastPopulatedPath;
manager.InitializeFiles(executionContext, null);
Assert.Equal(2, ext.PopulateCallCount);
Assert.NotEqual(firstPath, ext.LastPopulatedPath);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void InitializeFiles_PopulateException_DoesNotAbortInitialization()
{
using (var hostContext = Setup(out var executionContext, out var ext))
{
ext.ThrowOnPopulate = true;
var manager = new FileCommandManager();
manager.Initialize(hostContext);
// Must not throw — failures during populate should be
// swallowed so a misbehaving extension cannot block step
// setup.
manager.InitializeFiles(executionContext, null);
Assert.Equal(1, ext.PopulateCallCount);
}
}
private TestHostContext Setup(out IExecutionContext executionContext, out RecordingFileCommand recordingExtension, [CallerMemberName] string name = "")
{
var hostContext = new TestHostContext(this, name);
recordingExtension = new RecordingFileCommand();
recordingExtension.Initialize(hostContext);
var extensionManager = new Mock<IExtensionManager>();
extensionManager.Setup(x => x.GetExtensions<IFileCommandExtension>())
.Returns(new List<IFileCommandExtension> { recordingExtension });
hostContext.SetSingleton<IExtensionManager>(extensionManager.Object);
var ec = new Mock<IExecutionContext>();
ec.Setup(x => x.SetGitHubContext(It.IsAny<string>(), It.IsAny<string>()));
executionContext = ec.Object;
return hostContext;
}
private sealed class RecordingFileCommand : RunnerService, IFileCommandExtension
{
public string ContextName => "recording";
public string FilePrefix => "recording_";
public Type ExtensionType => typeof(IFileCommandExtension);
public int PopulateCallCount { get; private set; }
public string LastPopulatedPath { get; private set; }
public bool ThrowOnPopulate { get; set; }
public void PopulateInitialContents(IExecutionContext context, string filePath, ContainerInfo container)
{
PopulateCallCount++;
LastPopulatedPath = filePath;
if (ThrowOnPopulate)
{
throw new InvalidOperationException("intentional");
}
}
public void ProcessCommand(IExecutionContext context, string filePath, ContainerInfo container)
{
}
}
}
}

View File

@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
DOTNETSDK_VERSION="8.0.421"
DOTNETSDK_VERSION="8.0.422"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.421"
"version": "8.0.422"
}
}

View File

@@ -1 +1 @@
2.334.0
2.335.0