mirror of
https://github.com/actions/runner.git
synced 2026-07-08 06:14:20 +08:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9fc8e4ba |
@@ -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`).
|
||||
|
||||
|
||||
@@ -180,7 +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";
|
||||
}
|
||||
|
||||
@@ -229,12 +228,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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.3" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,6 @@ namespace GitHub.Runner.Worker
|
||||
"actor",
|
||||
"actor_id",
|
||||
"api_url",
|
||||
"artifacts",
|
||||
"artifacts_list",
|
||||
"base_ref",
|
||||
"env",
|
||||
"event_name",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user