From 7d6e878b01093209cba30739d2154f2ad256f980 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Tue, 2 Jun 2026 10:32:49 -0700 Subject: [PATCH] artifact support Signed-off-by: Brian DeHamer --- src/Runner.Common/Constants.cs | 7 + src/Runner.Common/ExtensionManager.cs | 2 + src/Runner.Worker/ArtifactSubject.cs | 52 ++ src/Runner.Worker/ArtifactsListFileCommand.cs | 92 +++ .../CreateArtifactsFileCommand.cs | 384 +++++++++++ src/Runner.Worker/ExecutionContext.cs | 3 + src/Runner.Worker/FileCommandManager.cs | 20 + src/Runner.Worker/GitHubContext.cs | 2 + src/Runner.Worker/GlobalContext.cs | 4 + .../L0/Worker/ArtifactsListFileCommandL0.cs | 214 ++++++ .../L0/Worker/CreateArtifactsFileCommandL0.cs | 627 ++++++++++++++++++ src/Test/L0/Worker/FileCommandManagerL0.cs | 105 +++ 12 files changed, 1512 insertions(+) create mode 100644 src/Runner.Worker/ArtifactSubject.cs create mode 100644 src/Runner.Worker/ArtifactsListFileCommand.cs create mode 100644 src/Runner.Worker/CreateArtifactsFileCommand.cs create mode 100644 src/Test/L0/Worker/ArtifactsListFileCommandL0.cs create mode 100644 src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs create mode 100644 src/Test/L0/Worker/FileCommandManagerL0.cs diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 8536e942a27..1d9b306d101 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -180,6 +180,7 @@ public static class Features 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"; } // Node version migration related constants @@ -227,6 +228,12 @@ public static class NodeMigration 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 diff --git a/src/Runner.Common/ExtensionManager.cs b/src/Runner.Common/ExtensionManager.cs index 2b7810eca45..b0bc2fef01e 100644 --- a/src/Runner.Common/ExtensionManager.cs +++ b/src/Runner.Common/ExtensionManager.cs @@ -62,6 +62,8 @@ private List LoadExtensions() where T : class, IExtension Add(extensions, "GitHub.Runner.Worker.CreateStepSummaryCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SaveStateFileCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SetOutputFileCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.CreateArtifactsFileCommand, Runner.Worker"); + Add(extensions, "GitHub.Runner.Worker.ArtifactsListFileCommand, Runner.Worker"); break; case "GitHub.Runner.Listener.Check.ICheckExtension": Add(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener"); diff --git a/src/Runner.Worker/ArtifactSubject.cs b/src/Runner.Worker/ArtifactSubject.cs new file mode 100644 index 00000000000..fb90c896646 --- /dev/null +++ b/src/Runner.Worker/ArtifactSubject.cs @@ -0,0 +1,52 @@ +using System; + +namespace GitHub.Runner.Worker +{ + public enum ArtifactSubjectKind + { + File, + OciSubject, + } + + /// + /// Represents a single artifact subject declared via the + /// GITHUB_ARTIFACTS per-step environment file. + /// + public sealed class ArtifactSubject : IEquatable + { + 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}"; + } +} diff --git a/src/Runner.Worker/ArtifactsListFileCommand.cs b/src/Runner.Worker/ArtifactsListFileCommand.cs new file mode 100644 index 00000000000..aaad5695384 --- /dev/null +++ b/src/Runner.Worker/ArtifactsListFileCommand.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker +{ + /// + /// File command extension that exposes the job-scoped aggregate of + /// as a read-only JSON + /// file. Subsequent steps in the same job read the file via the + /// GITHUB_ARTIFACTS_LIST environment variable, getting a + /// running view of every artifact declared via + /// $GITHUB_ARTIFACTS in earlier steps. + /// + /// + /// The file uses the existing per-step file-command lifecycle: + /// creates a fresh + /// file, invokes 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 {"version":1,"subjects":[]}. + /// + 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(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. + } + } +} diff --git a/src/Runner.Worker/CreateArtifactsFileCommand.cs b/src/Runner.Worker/CreateArtifactsFileCommand.cs new file mode 100644 index 00000000000..da0d694d64f --- /dev/null +++ b/src/Runner.Worker/CreateArtifactsFileCommand.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; + +namespace GitHub.Runner.Worker +{ + /// + /// File command extension that implements the GITHUB_ARTIFACTS + /// per-step environment file contract. + /// + /// + /// Lifecycle is identical to the other per-step file commands: + /// creates an empty file before each + /// step runs and invokes 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 + /// at job scope. + /// + /// The feature is gated by the actions_runner_allow_artifacts_file + /// feature flag. When the flag is disabled, the env var is still + /// exposed but writes are silently ignored. + /// + 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 "://...". 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 "@:" where is sha256/sha384/sha512. + // Hex length is validated separately so we can produce a precise error. + private static readonly Regex s_ociDigestSuffixRegex = new( + @"^(?.+)@(?sha(?:256|384|512)):(?[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}: 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) { } + } + } +} diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 6d7698fdd9c..0f9410821c6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -973,6 +973,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // Track actions stuck on Node.js 20 due to ARM32 (separate from general deprecation) Global.Arm32Node20Actions = new HashSet(StringComparer.OrdinalIgnoreCase); + // Job-scoped aggregate of artifact subjects declared via $GITHUB_ARTIFACTS. + Global.ArtifactSubjects = new Dictionary(StringComparer.Ordinal); + // Job Outputs JobOutputs = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Runner.Worker/FileCommandManager.cs b/src/Runner.Worker/FileCommandManager.cs index 7c3c0ef431a..9d8bbebb42b 100644 --- a/src/Runner.Worker/FileCommandManager.cs +++ b/src/Runner.Worker/FileCommandManager.cs @@ -55,6 +55,18 @@ public void InitializeFiles(IExecutionContext context, ContainerInfo container) TryDeleteFile(newPath); File.Create(newPath).Dispose(); + // Give extensions a chance to populate the file before + // the step starts (e.g., read-only views of job state). + // Errors are logged but must not fail step setup. + try + { + fileCommand.PopulateInitialContents(context, newPath, container); + } + catch (Exception ex) + { + _trace.Warning($"Failed to populate initial contents for file command '{fileCommand.ContextName}': {ex}"); + } + var pathToSet = container != null ? container.TranslateToContainerPath(newPath) : newPath; context.SetGitHubContext(fileCommand.ContextName, pathToSet); } @@ -102,6 +114,14 @@ public interface IFileCommandExtension : IExtension 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 diff --git a/src/Runner.Worker/GitHubContext.cs b/src/Runner.Worker/GitHubContext.cs index 710469ddbc1..3d01e4f7db5 100644 --- a/src/Runner.Worker/GitHubContext.cs +++ b/src/Runner.Worker/GitHubContext.cs @@ -15,6 +15,8 @@ public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextDa "actor", "actor_id", "api_url", + "artifacts", + "artifacts_list", "base_ref", "env", "event_name", diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 04abe003633..c2db20bd5ac 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -39,5 +39,9 @@ public sealed class GlobalContext public HashSet UpgradedToNode24Actions { get; set; } public HashSet Arm32Node20Actions { get; set; } public IList 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 ArtifactSubjects { get; set; } } } diff --git a/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs new file mode 100644 index 00000000000..35f03fdf1f5 --- /dev/null +++ b/src/Test/L0/Worker/ArtifactsListFileCommandL0.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class ArtifactsListFileCommandL0 + { + private Mock _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()); + 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()); + Assert.Equal("sha256:" + new string('a', 64), subjects[0]["digest"].Value()); + Assert.Equal("file", subjects[0]["kind"].Value()); + } + } + + [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()); + } + } + + [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()); + Assert.Equal("three", subjects[1]["name"].Value()); + Assert.Equal("two", subjects[2]["name"].Value()); + } + } + + [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(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(VarUtil.EnvironmentVariableKeyComparer), + Variables = variables, + WriteDebug = true, + ArtifactSubjects = new Dictionary(StringComparer.Ordinal), + }; + + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global).Returns(_global); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + + _command = new ArtifactsListFileCommand(); + _command.Initialize(hostContext); + + return hostContext; + } + } +} diff --git a/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs new file mode 100644 index 00000000000..79bcc60890a --- /dev/null +++ b/src/Test/L0/Worker/CreateArtifactsFileCommandL0.cs @@ -0,0 +1,627 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using Moq; +using Xunit; +using DTWebApi = GitHub.DistributedTask.WebApi; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class CreateArtifactsFileCommandL0 + { + private const string FlagOn = "true"; + + private Mock _executionContext; + private List _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(() => _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(); + + // 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(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(VarUtil.EnvironmentVariableKeyComparer), + Variables = variables, + WriteDebug = true, + ArtifactSubjects = new Dictionary(StringComparer.Ordinal), + }; + + _executionContext = new Mock(); + _executionContext.Setup(x => x.Global).Returns(_global); + _executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(_workspaceDirectory); + _executionContext.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())) + .Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) => + { + _issues.Add(issue); + _trace.Info($"Issue '{issue.Type}': {issue.Message}"); + }); + _executionContext.Setup(x => x.Write(It.IsAny(), It.IsAny())) + .Callback((string tag, string message) => + { + _trace.Info($"{tag}{message}"); + }); + + _command = new CreateArtifactsFileCommand(); + _command.Initialize(hostContext); + + return hostContext; + } + } +} diff --git a/src/Test/L0/Worker/FileCommandManagerL0.cs b/src/Test/L0/Worker/FileCommandManagerL0.cs new file mode 100644 index 00000000000..e09253403d5 --- /dev/null +++ b/src/Test/L0/Worker/FileCommandManagerL0.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class FileCommandManagerL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeFiles_InvokesPopulateInitialContents_OncePerExtension() + { + using (var hostContext = Setup(out var executionContext, out var ext)) + { + var manager = new FileCommandManager(); + manager.Initialize(hostContext); + + manager.InitializeFiles(executionContext, null); + + Assert.Equal(1, ext.PopulateCallCount); + Assert.True(File.Exists(ext.LastPopulatedPath)); + + // A second invocation should populate again with the new + // per-step file (file path rotates between calls). + var firstPath = ext.LastPopulatedPath; + manager.InitializeFiles(executionContext, null); + Assert.Equal(2, ext.PopulateCallCount); + Assert.NotEqual(firstPath, ext.LastPopulatedPath); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeFiles_PopulateException_DoesNotAbortInitialization() + { + using (var hostContext = Setup(out var executionContext, out var ext)) + { + ext.ThrowOnPopulate = true; + + var manager = new FileCommandManager(); + manager.Initialize(hostContext); + + // Must not throw — failures during populate should be + // swallowed so a misbehaving extension cannot block step + // setup. + manager.InitializeFiles(executionContext, null); + + Assert.Equal(1, ext.PopulateCallCount); + } + } + + private TestHostContext Setup(out IExecutionContext executionContext, out RecordingFileCommand recordingExtension, [CallerMemberName] string name = "") + { + var hostContext = new TestHostContext(this, name); + + recordingExtension = new RecordingFileCommand(); + recordingExtension.Initialize(hostContext); + + var extensionManager = new Mock(); + extensionManager.Setup(x => x.GetExtensions()) + .Returns(new List { recordingExtension }); + hostContext.SetSingleton(extensionManager.Object); + + var ec = new Mock(); + ec.Setup(x => x.SetGitHubContext(It.IsAny(), It.IsAny())); + 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) + { + } + } + } +}