Compare commits

..

3 Commits

Author SHA1 Message Date
Lokesh Gopu
7d737449ef Bump to 2.335.1 (#4484)
Co-authored-by: Francesco Renzi <rentziass@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 21:28:02 -04:00
Francesco Renzi
0d310567ae Update releaseVersion 2026-06-08 18:04:53 +01:00
Francesco Renzi
1ccca7c073 Prepping runner release 2.335.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 17:47:11 +01:00
37 changed files with 72 additions and 2624 deletions

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
# 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@v7
- uses: actions/checkout@v6
- 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@v7
uses: actions/checkout@v6
- name: Check Docker version
id: check_docker_version
@@ -89,7 +89,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- 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@v7
uses: actions/checkout@v6
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@v7
uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
# 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@v7
- uses: actions/checkout@v6
# Build runner layout
- name: Build & Layout Release
@@ -129,7 +129,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
# 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@v7
uses: actions/checkout@v6
- 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](../../src/Misc/layoutroot/config.sh) will check .Net Core 3.x dependencies during runner configuration.
The `./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 [installdependencies.sh](../../src/Misc/layoutbin/installdependencies.sh).
You can easily correct the problem by executing `./bin/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.6.1
ARG BUILDX_VERSION=0.35.0
ARG DOCKER_VERSION=29.5.3
ARG BUILDX_VERSION=0.34.1
RUN apt update -y && apt install curl unzip -y

View File

@@ -1 +1 @@
<Update to ./src/runnerversion when creating release>
2.335.1

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

View File

@@ -180,8 +180,6 @@ 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
@@ -229,12 +227,6 @@ 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,8 +62,6 @@ 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), string selfRepoName = null, string selfRepoRef = null)
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))
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (depth > Constants.CompositeActionsMaxDepth)
@@ -186,21 +186,6 @@ 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)
@@ -243,30 +228,7 @@ namespace GitHub.Runner.Worker
{
throw new Exception($"Missing download info for {lookupKey}");
}
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)}"
});
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
}
// Parse action.yml and collect composite sub-actions for batched
@@ -316,53 +278,16 @@ namespace GitHub.Runner.Worker
// then recurse per parent (which hits the cache, not the API).
if (nextLevel.Count > 0)
{
if (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.SelfRepository) == true)
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))
{
// 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);
}
var groupActions = group.Select(x => x.action).ToList();
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
}
}
@@ -438,25 +363,13 @@ 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), string selfRepoName = null, string selfRepoRef = null)
private async Task<PrepareActionsState> PrepareActionsRecursiveLegacyAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
{
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)
@@ -485,30 +398,7 @@ namespace GitHub.Runner.Worker
if (repositoryActions.Count > 0)
{
// Get the download info
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)}"
});
}
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
// Download each action
foreach (var action in repositoryActions)
@@ -524,29 +414,7 @@ namespace GitHub.Runner.Worker
throw new Exception($"Missing download info for {lookupKey}");
}
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)}"
});
}
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
}
// More preparation based on content in the repository (action.yml)
@@ -581,17 +449,7 @@ namespace GitHub.Runner.Worker
}
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
{
// 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);
state = await PrepareActionsRecursiveLegacyAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
}
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
@@ -704,12 +562,6 @@ 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);
@@ -845,27 +697,6 @@ 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
{
@@ -1149,33 +980,10 @@ namespace GitHub.Runner.Worker
if (actionsToResolve.Count > 0)
{
IDictionary<string, WebApi.ActionDownloadInfo> downloadInfos = null;
Exception resolveFailure = null;
try
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
foreach (var kvp in downloadInfos)
{
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)}"
});
resolvedDownloadInfos[kvp.Key] = kvp.Value;
}
}
}
@@ -1300,6 +1108,12 @@ 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);
@@ -1308,13 +1122,6 @@ 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
{
@@ -1352,6 +1159,7 @@ 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}.");
}
@@ -1401,12 +1209,6 @@ 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;
@@ -1545,47 +1347,6 @@ 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)
@@ -1601,11 +1362,6 @@ 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

@@ -1,52 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,384 +0,0 @@
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,9 +973,6 @@ 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,18 +55,6 @@ 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);
}
@@ -114,14 +102,6 @@ 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,8 +15,6 @@ namespace GitHub.Runner.Worker
"actor",
"actor_id",
"api_url",
"artifacts",
"artifacts_list",
"base_ref",
"env",
"event_name",

View File

@@ -39,9 +39,5 @@ 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

@@ -4,7 +4,6 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Threading;
@@ -46,7 +45,6 @@ namespace GitHub.Runner.Worker
{
private readonly HashSet<string> _existingProcesses = new(StringComparer.OrdinalIgnoreCase);
private readonly List<Task<CheckResult>> _connectivityCheckTasks = new();
private readonly List<Task<CheckResult>> _connectivityAndDNSCheckTasks = new();
private bool _processCleanup;
private string _processLookupId = $"github_{Guid.NewGuid()}";
private CancellationTokenSource _diskSpaceCheckToken = new();
@@ -604,21 +602,7 @@ namespace GitHub.Runner.Worker
{
foreach (var checkUrl in checkUrls)
{
_connectivityCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5));
}
}
}
if (systemConnection.Data.TryGetValue("ConnectivityAndDNSChecks", out var connectivityAndDNSChecksPayload) &&
!string.IsNullOrEmpty(connectivityAndDNSChecksPayload))
{
Trace.Info($"Start checking server connectivity and DNS.");
var checkUrls = StringUtil.ConvertFromJson<List<string>>(connectivityAndDNSChecksPayload);
if (checkUrls?.Count > 0)
{
foreach (var checkUrl in checkUrls)
{
_connectivityAndDNSCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5, checkDNS: true));
_connectivityCheckTasks.Add(CheckConnectivity(checkUrl, accessToken: string.Empty, timeoutInSeconds: 5, token: CancellationToken.None));
}
}
}
@@ -917,7 +901,7 @@ namespace GitHub.Runner.Worker
foreach (var check in _connectivityCheckTasks)
{
var result = await check;
Trace.Info($"Connectivity check result: {StringUtil.ConvertToJson(result)}");
Trace.Info($"Connectivity check result: {result}");
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"{result.EndpointUrl}: {result.StatusCode}" });
}
}
@@ -929,27 +913,6 @@ namespace GitHub.Runner.Worker
}
}
if (_connectivityAndDNSCheckTasks.Count > 0)
{
try
{
Trace.Info($"Wait for all connectivity and DNS checks to finish.");
await Task.WhenAll(_connectivityAndDNSCheckTasks);
foreach (var check in _connectivityAndDNSCheckTasks)
{
var result = await check;
Trace.Info($"Connectivity and DNS check result: {StringUtil.ConvertToJson(result)}");
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"connectivity_dns_telemetry:{StringUtil.ConvertToJson(result, Formatting.None)}" });
}
}
catch (Exception ex)
{
Trace.Error($"Fail to check server connectivity and DNS.");
Trace.Error(ex);
context.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.ConnectivityCheck, Message = $"Fail to check server connectivity and DNS. {ex.Message}" });
}
}
// Collect service connectivity check result
if (_serviceConnectivityCheckTask != null)
{
@@ -1041,43 +1004,16 @@ namespace GitHub.Runner.Worker
}
}
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, bool checkDNS = false, CancellationToken token = default)
private async Task<CheckResult> CheckConnectivity(string endpointUrl, string accessToken, int timeoutInSeconds, CancellationToken token)
{
Trace.Info($"Check server connectivity for {endpointUrl}.");
CheckResult result = new CheckResult() { EndpointUrl = endpointUrl };
var stopwatch = Stopwatch.StartNew();
using (var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutInSeconds)))
using (var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutTokenSource.Token))
{
if (checkDNS)
{
try
{
var dnsStopwatch = Stopwatch.StartNew();
var addresses = await Dns.GetHostAddressesAsync(new Uri(endpointUrl).Host, linkedTokenSource.Token);
dnsStopwatch.Stop();
result.DNSResolutionDurationInMs = (int)dnsStopwatch.ElapsedMilliseconds;
result.EndpointIPs = addresses.Select(a => a.ToString()).ToArray();
}
catch (Exception ex) when (ex is OperationCanceledException && token.IsCancellationRequested)
{
Trace.Error($"DNS resolution canceled: {ex}");
result.DNSError = "dns_canceled";
}
catch (Exception ex) when (ex is OperationCanceledException && timeoutTokenSource.IsCancellationRequested)
{
Trace.Error($"DNS resolution timeout: {ex}");
result.DNSError = "dns_timeout";
}
catch (Exception ex)
{
Trace.Error($"Catch exception during DNS resolution: {ex}");
result.DNSError = $"dns_{ex.Message}";
}
}
try
{
var httpStopwatch = Stopwatch.StartNew();
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
{
@@ -1088,7 +1024,7 @@ namespace GitHub.Runner.Worker
}
var response = await httpClient.GetAsync(endpointUrl, linkedTokenSource.Token);
result.StatusCode = $"http_{response.StatusCode}";
result.StatusCode = $"{response.StatusCode}";
var githubRequestId = UrlUtil.GetGitHubRequestId(response.Headers);
var vssRequestId = UrlUtil.GetVssRequestId(response.Headers);
@@ -1100,26 +1036,26 @@ namespace GitHub.Runner.Worker
{
result.RequestId = vssRequestId;
}
httpStopwatch.Stop();
result.HttpRequestDurationInMs = (int)httpStopwatch.ElapsedMilliseconds;
}
}
catch (Exception ex) when (ex is OperationCanceledException && token.IsCancellationRequested)
{
Trace.Error($"Request canceled during connectivity check: {ex}");
result.StatusCode = "http_canceled";
result.StatusCode = "canceled";
}
catch (Exception ex) when (ex is OperationCanceledException && timeoutTokenSource.IsCancellationRequested)
{
Trace.Error($"Request timeout during connectivity check: {ex}");
result.StatusCode = "http_timeout";
result.StatusCode = "timeout";
}
catch (Exception ex)
{
Trace.Error($"Catch exception during connectivity check: {ex}");
result.StatusCode = $"http_{ex.Message}";
result.StatusCode = $"{ex.Message}";
}
}
stopwatch.Stop();
result.DurationInMs = (int)stopwatch.ElapsedMilliseconds;
return result;
}
@@ -1204,8 +1140,8 @@ namespace GitHub.Runner.Worker
try
{
var result = await CheckConnectivity(endpoint.Value, accessToken: accessToken, timeoutInSeconds: checkConnectivityInfo.RequestTimeoutInSecond, token: token);
testResult.EndpointsResult[endpoint.Key].Add($"{result.StartTime:s}: {result.StatusCode} - {result.RequestId} - {result.HttpRequestDurationInMs}ms");
var result = await CheckConnectivity(endpoint.Value, accessToken: accessToken, timeoutInSeconds: checkConnectivityInfo.RequestTimeoutInSecond, token);
testResult.EndpointsResult[endpoint.Key].Add($"{result.StartTime:s}: {result.StatusCode} - {result.RequestId} - {result.DurationInMs}ms");
if (!testResult.HasFailure &&
result.StatusCode != "OK" &&
result.StatusCode != "canceled")
@@ -1276,19 +1212,13 @@ namespace GitHub.Runner.Worker
public string EndpointUrl { get; set; }
public string[] EndpointIPs { get; set; }
public DateTime StartTime { get; set; }
public string StatusCode { get; set; }
public string RequestId { get; set; }
public int HttpRequestDurationInMs { get; set; }
public int DNSResolutionDurationInMs { get; set; }
public string DNSError { get; set; }
public int DurationInMs { get; set; }
}
}
}

View File

@@ -55,18 +55,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating
break;
case ActionSourceType.Repository:
var repositoryReference = step.Reference as RepositoryPathReference;
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;
}
name = !String.IsNullOrEmpty(repositoryReference.Name) ? repositoryReference.Name : PipelineConstants.SelfAlias;
break;
}
@@ -611,14 +600,6 @@ 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,43 +38,10 @@ namespace GitHub.DistributedTask.Pipelines
public static readonly Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self local-workspace repository type (./ syntax).
/// Resolves to the local checkout on the runner.
/// Alias for the self repository.
/// </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,10 +1605,6 @@ 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,15 +26,10 @@ namespace GitHub.Actions.WorkflowParser
internal const Int32 MaxNodeNameLength = 100;
/// <summary>
/// Alias for the self local-workspace repository type (./ syntax).
/// Alias for the self repository.
/// </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,11 +90,6 @@ 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
@@ -153,11 +148,6 @@ 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
{
@@ -225,51 +215,6 @@ 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")]
@@ -585,9 +530,9 @@ runs:
//Assert
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
var di = new DirectoryInfo(destDirectory);
Assert.NotNull(di.LinkTarget);
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
var di = new DirectoryInfo(destDirectory);
Assert.NotNull(di.LinkTarget);
}
finally
{
@@ -2441,7 +2386,7 @@ runs:
}
}
[Fact]
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void LoadsNode24ActionDefinition()
@@ -2509,7 +2454,7 @@ runs:
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -3388,16 +3333,6 @@ 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")]
@@ -3533,604 +3468,5 @@ 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

@@ -1,214 +0,0 @@
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

@@ -1,627 +0,0 @@
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

@@ -1,105 +0,0 @@
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.422"
DOTNETSDK_VERSION="8.0.421"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

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

View File

@@ -1 +1 @@
2.335.0
2.335.1