diff --git a/src/Runner.Common/HostContext.cs b/src/Runner.Common/HostContext.cs index b9a44fa2b..8126f8c95 100644 --- a/src/Runner.Common/HostContext.cs +++ b/src/Runner.Common/HostContext.cs @@ -1,19 +1,18 @@ -using GitHub.Runner.Common.Util; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Tracing; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Reflection; using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; -using System.Diagnostics; -using System.Net.Http; -using System.Diagnostics.Tracing; using GitHub.DistributedTask.Logging; -using System.Net.Http.Headers; using GitHub.Runner.Sdk; namespace GitHub.Runner.Common @@ -615,9 +614,8 @@ namespace GitHub.Runner.Common { public static HttpClientHandler CreateHttpClientHandler(this IHostContext context) { - HttpClientHandler clientHandler = new HttpClientHandler(); - clientHandler.Proxy = context.WebProxy; - return clientHandler; + var handlerFactory = context.GetService(); + return handlerFactory.CreateClientHandler(context.WebProxy); } } diff --git a/src/Runner.Common/HttpClientHandlerFactory.cs b/src/Runner.Common/HttpClientHandlerFactory.cs new file mode 100644 index 000000000..f507dd7af --- /dev/null +++ b/src/Runner.Common/HttpClientHandlerFactory.cs @@ -0,0 +1,19 @@ +using System.Net.Http; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Common +{ + [ServiceLocator(Default = typeof(HttpClientHandlerFactory))] + public interface IHttpClientHandlerFactory : IRunnerService + { + HttpClientHandler CreateClientHandler(RunnerWebProxy webProxy); + } + + public class HttpClientHandlerFactory : RunnerService, IHttpClientHandlerFactory + { + public HttpClientHandler CreateClientHandler(RunnerWebProxy webProxy) + { + return new HttpClientHandler() { Proxy = webProxy }; + } + } +} \ No newline at end of file diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 533841659..6865a7852 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -1,21 +1,19 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Threading; +using System.Threading.Tasks; using GitHub.DistributedTask.ObjectTemplating.Tokens; -using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; -using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker.Container; using GitHub.Services.Common; -using Newtonsoft.Json; using Pipelines = GitHub.DistributedTask.Pipelines; using PipelineTemplateConstants = GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants; @@ -73,13 +71,7 @@ namespace GitHub.Runner.Worker } // Clear the cache (for self-hosted runners) - // Note, temporarily avoid this step for the on-premises product, to avoid rate limiting. - var configurationStore = HostContext.GetService(); - var isHostedServer = configurationStore.GetSettings().IsHostedServer; - if (isHostedServer) - { - IOUtil.DeleteDirectory(HostContext.GetDirectory(WellKnownDirectory.Actions), executionContext.CancellationToken); - } + IOUtil.DeleteDirectory(HostContext.GetDirectory(WellKnownDirectory.Actions), executionContext.CancellationToken); foreach (var action in actions) { @@ -490,7 +482,7 @@ namespace GitHub.Runner.Worker ArgUtil.NotNullOrEmpty(repositoryReference.Ref, nameof(repositoryReference.Ref)); string destDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repositoryReference.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repositoryReference.Ref); - string watermarkFile = destDirectory + ".completed"; + string watermarkFile = GetWatermarkFilePath(destDirectory); if (File.Exists(watermarkFile)) { executionContext.Debug($"Action '{repositoryReference.Name}@{repositoryReference.Ref}' already downloaded at '{destDirectory}'."); @@ -504,27 +496,84 @@ namespace GitHub.Runner.Worker executionContext.Output($"Download action repository '{repositoryReference.Name}@{repositoryReference.Ref}'"); } -#if OS_WINDOWS - string archiveLink = $"https://api.github.com/repos/{repositoryReference.Name}/zipball/{repositoryReference.Ref}"; -#else - string archiveLink = $"https://api.github.com/repos/{repositoryReference.Name}/tarball/{repositoryReference.Ref}"; -#endif - Trace.Info($"Download archive '{archiveLink}' to '{destDirectory}'."); + var configurationStore = HostContext.GetService(); + var isHostedServer = configurationStore.GetSettings().IsHostedServer; + if (isHostedServer) + { + string apiUrl = GetApiUrl(executionContext); + string archiveLink = BuildLinkToActionArchive(apiUrl, repositoryReference.Name, repositoryReference.Ref); + Trace.Info($"Download archive '{archiveLink}' to '{destDirectory}'."); + await DownloadRepositoryActionAsync(executionContext, archiveLink, destDirectory); + return; + } + else + { + string apiUrl = GetApiUrl(executionContext); + // URLs to try: + var archiveLinks = new List { + // A built-in action or an action the user has created, on their GHES instance + // Example: https://my-ghes/api/v3/repos/my-org/my-action/tarball/v1 + BuildLinkToActionArchive(apiUrl, repositoryReference.Name, repositoryReference.Ref), + + // A community action, synced to their GHES instance + // Example: https://my-ghes/api/v3/repos/actions-community/some-org-some-action/tarball/v1 + BuildLinkToActionArchive(apiUrl, $"actions-community/{repositoryReference.Name.Replace("/", "-")}", repositoryReference.Ref) + }; + + foreach (var archiveLink in archiveLinks) + { + Trace.Info($"Download archive '{archiveLink}' to '{destDirectory}'."); + try + { + await DownloadRepositoryActionAsync(executionContext, archiveLink, destDirectory); + return; + } + catch (ActionNotFoundException) + { + Trace.Info($"Failed to find the action '{repositoryReference.Name}' at ref '{repositoryReference.Ref}' at {archiveLink}"); + continue; + } + } + throw new ActionNotFoundException($"Failed to find the action '{repositoryReference.Name}' at ref '{repositoryReference.Ref}'. Paths attempted: {string.Join(", ", archiveLinks)}"); + } + } + + private string GetApiUrl(IExecutionContext executionContext) + { + string apiUrl = executionContext.GetGitHubContext("api_url"); + if (!string.IsNullOrEmpty(apiUrl)) + { + return apiUrl; + } + // Once the api_url is set for hosted, we can remove this fallback (it doesn't make sense for GHES) + return "https://api.github.com"; + } + + private static string BuildLinkToActionArchive(string apiUrl, string repository, string @ref) + { +#if OS_WINDOWS + return $"{apiUrl}/repos/{repository}/zipball/{@ref}"; +#else + return $"{apiUrl}/repos/{repository}/tarball/{@ref}"; +#endif + } + + private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, string link, string destDirectory) + { //download and extract action in a temp folder and rename it on success string tempDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), "_temp_" + Guid.NewGuid()); Directory.CreateDirectory(tempDirectory); - #if OS_WINDOWS string archiveFile = Path.Combine(tempDirectory, $"{Guid.NewGuid()}.zip"); #else string archiveFile = Path.Combine(tempDirectory, $"{Guid.NewGuid()}.tar.gz"); #endif - Trace.Info($"Save archive '{archiveLink}' into {archiveFile}."); + + Trace.Info($"Save archive '{link}' into {archiveFile}."); try { - int retryCount = 0; // Allow up to 20 * 60s for any action to be downloaded from github graph. @@ -541,64 +590,76 @@ namespace GitHub.Runner.Worker using (var httpClientHandler = HostContext.CreateHttpClientHandler()) using (var httpClient = new HttpClient(httpClientHandler)) { - var configurationStore = HostContext.GetService(); - var isHostedServer = configurationStore.GetSettings().IsHostedServer; - if (isHostedServer) + var authToken = Environment.GetEnvironmentVariable("_GITHUB_ACTION_TOKEN"); + if (string.IsNullOrEmpty(authToken)) { - var authToken = Environment.GetEnvironmentVariable("_GITHUB_ACTION_TOKEN"); - if (string.IsNullOrEmpty(authToken)) - { - // TODO: Deprecate the PREVIEW_ACTION_TOKEN - authToken = executionContext.Variables.Get("PREVIEW_ACTION_TOKEN"); - } + // TODO: Deprecate the PREVIEW_ACTION_TOKEN + authToken = executionContext.Variables.Get("PREVIEW_ACTION_TOKEN"); + } - if (!string.IsNullOrEmpty(authToken)) - { - HostContext.SecretMasker.AddValue(authToken); - var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"PAT:{authToken}")); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken); - } - else - { - var accessToken = executionContext.GetGitHubContext("token"); - var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{accessToken}")); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken); - } + if (!string.IsNullOrEmpty(authToken)) + { + HostContext.SecretMasker.AddValue(authToken); + var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"PAT:{authToken}")); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken); } else { - // Intentionally empty. Temporary for GHES alpha release, download from dotcom unauthenticated. + var accessToken = executionContext.GetGitHubContext("token"); + var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{accessToken}")); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken); } httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents); - using (var result = await httpClient.GetStreamAsync(archiveLink)) + using (var response = await httpClient.GetAsync(link)) { - await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token); - await fs.FlushAsync(actionDownloadCancellation.Token); + if (response.IsSuccessStatusCode) + { + using (var result = await response.Content.ReadAsStreamAsync()) + { + await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token); + await fs.FlushAsync(actionDownloadCancellation.Token); - // download succeed, break out the retry loop. - break; + // download succeed, break out the retry loop. + break; + } + } + else if (response.StatusCode == HttpStatusCode.NotFound) + { + // It doesn't make sense to retry in this case, so just stop + throw new ActionNotFoundException(new Uri(link)); + } + else + { + // Something else bad happened, let's go to our retry logic + response.EnsureSuccessStatusCode(); + } } } } catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested) { - Trace.Info($"Action download has been cancelled."); + Trace.Info("Action download has been cancelled."); + throw; + } + catch (ActionNotFoundException) + { + Trace.Info($"The action at '{link}' does not exist"); throw; } catch (Exception ex) when (retryCount < 2) { retryCount++; - Trace.Error($"Fail to download archive '{archiveLink}' -- Attempt: {retryCount}"); + Trace.Error($"Fail to download archive '{link}' -- Attempt: {retryCount}"); Trace.Error(ex); if (actionDownloadTimeout.Token.IsCancellationRequested) { // action download didn't finish within timeout - executionContext.Warning($"Action '{archiveLink}' didn't finish download within {timeoutSeconds} seconds."); + executionContext.Warning($"Action '{link}' didn't finish download within {timeoutSeconds} seconds."); } else { - executionContext.Warning($"Failed to download action '{archiveLink}'. Error {ex.Message}"); + executionContext.Warning($"Failed to download action '{link}'. Error: {ex.Message}"); } } } @@ -612,7 +673,7 @@ namespace GitHub.Runner.Worker } ArgUtil.NotNullOrEmpty(archiveFile, nameof(archiveFile)); - executionContext.Debug($"Download '{archiveLink}' to '{archiveFile}'"); + executionContext.Debug($"Download '{link}' to '{archiveFile}'"); var stagingDirectory = Path.Combine(tempDirectory, "_staging"); Directory.CreateDirectory(stagingDirectory); @@ -662,6 +723,7 @@ namespace GitHub.Runner.Worker } Trace.Verbose("Create watermark file indicate action download succeed."); + string watermarkFile = GetWatermarkFilePath(destDirectory); File.WriteAllText(watermarkFile, DateTime.UtcNow.ToString()); executionContext.Debug($"Archive '{archiveFile}' has been unzipped into '{destDirectory}'."); @@ -686,6 +748,8 @@ namespace GitHub.Runner.Worker } } + private string GetWatermarkFilePath(string directory) => directory + ".completed"; + private ActionContainer PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction) { var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference; @@ -931,4 +995,3 @@ namespace GitHub.Runner.Worker public string ActionRepository { get; set; } } } - diff --git a/src/Runner.Worker/ActionNotFoundException.cs b/src/Runner.Worker/ActionNotFoundException.cs new file mode 100644 index 000000000..9e67af44f --- /dev/null +++ b/src/Runner.Worker/ActionNotFoundException.cs @@ -0,0 +1,33 @@ +using System; +using System.Runtime.Serialization; + +namespace GitHub.Runner.Worker +{ + public class ActionNotFoundException : Exception + { + public ActionNotFoundException(Uri actionUri) + : base(FormatMessage(actionUri)) + { + } + + public ActionNotFoundException(string message) + : base(message) + { + } + + public ActionNotFoundException(string message, System.Exception inner) + : base(message, inner) + { + } + + protected ActionNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + + private static string FormatMessage(Uri actionUri) + { + return $"An action could not be found at the URI '{actionUri}'"; + } + } +} \ No newline at end of file diff --git a/src/Test/L0/RunnerWebProxyL0.cs b/src/Test/L0/RunnerWebProxyL0.cs index b83371d6a..3c1704f6c 100644 --- a/src/Test/L0/RunnerWebProxyL0.cs +++ b/src/Test/L0/RunnerWebProxyL0.cs @@ -16,7 +16,9 @@ namespace GitHub.Runner.Common.Tests private static readonly List SkippedFiles = new List() { "Runner.Common\\HostContext.cs", - "Runner.Common/HostContext.cs" + "Runner.Common/HostContext.cs", + "Runner.Common\\HttpClientHandlerFactory.cs", + "Runner.Common/HttpClientHandlerFactory.cs" }; [Fact] diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 59d7ed17c..90d203147 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -1,19 +1,21 @@ -using GitHub.DistributedTask.Expressions2; -using GitHub.DistributedTask.ObjectTemplating.Tokens; -using GitHub.DistributedTask.Pipelines.ContextData; -using GitHub.DistributedTask.WebApi; -using GitHub.Runner.Common.Util; -using GitHub.Runner.Worker; -using GitHub.Runner.Worker.Container; -using Moq; -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; -using System.Reflection; +using System.Net; +using System.Net.Http; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container; +using Moq; +using Moq.Protected; using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; @@ -114,47 +116,175 @@ namespace GitHub.Runner.Common.Tests.Worker [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public async void PrepareActions_SkipDownloadActionFromGraphWhenCached_OnPremises() + public async void PrepareActions_DownloadBuiltInActionFromGraph_OnPremises() { try { // Arrange Setup(); - var actionId = Guid.NewGuid(); + const string ActionName = "actions/sample-action"; var actions = new List { new Pipelines.ActionStep() { Name = "action", - Id = actionId, + Id = Guid.NewGuid(), Reference = new Pipelines.RepositoryPathReference() { - Name = "actions/no-such-action", + Name = ActionName, Ref = "master", RepositoryType = "GitHub" } } }; - _configurationStore.Object.GetSettings().IsHostedServer = false; - var actionDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions/no-such-action", "master"); - Directory.CreateDirectory(actionDirectory); - var watermarkFile = $"{actionDirectory}.completed"; - File.WriteAllText(watermarkFile, DateTime.UtcNow.ToString()); - var actionFile = Path.Combine(actionDirectory, "action.yml"); - File.WriteAllText(actionFile, @" -name: ""no-such-action"" -runs: - using: node12 - main: no-such-action.js -"); - var testFile = Path.Combine(actionDirectory, "test-file"); - File.WriteAllText(testFile, "asdf"); - // Act + // Return a valid action from GHES via mock + const string ApiUrl = "https://ghes.example.com/api/v3"; + string expectedArchiveLink = GetLinkToActionArchive(ApiUrl, ActionName, "master"); + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + var mockClientHandler = new Mock(); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(expectedArchiveLink)), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }); + + var mockHandlerFactory = new Mock(); + mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).Returns(mockClientHandler.Object); + _hc.SetSingleton(mockHandlerFactory.Object); + + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns(ApiUrl); + _configurationStore.Object.GetSettings().IsHostedServer = false; + + //Act await _actionManager.PrepareActionsAsync(_ec.Object, actions); - // Assert - Assert.True(File.Exists(testFile)); + //Assert + var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master.completed"); + Assert.True(File.Exists(watermarkFile)); + + var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master", "action.yml"); + Assert.True(File.Exists(actionYamlFile)); + _hc.GetTrace().Info(File.ReadAllText(actionYamlFile)); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DownloadCommunityActionFromGraph_OnPremises() + { + try + { + // Arrange + Setup(); + const string ActionName = "ownerName/sample-action"; + const string MungedActionName = "actions-community/ownerName-sample-action"; + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = ActionName, + Ref = "master", + RepositoryType = "GitHub" + } + } + }; + + // Return a valid action from GHES via mock + const string ApiUrl = "https://ghes.example.com/api/v3"; + string builtInArchiveLink = GetLinkToActionArchive(ApiUrl, ActionName, "master"); + string mungedArchiveLink = GetLinkToActionArchive(ApiUrl, MungedActionName, "master"); + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + var mockClientHandler = new Mock(); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(builtInArchiveLink)), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(mungedArchiveLink)), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) }); + + var mockHandlerFactory = new Mock(); + mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).Returns(mockClientHandler.Object); + _hc.SetSingleton(mockHandlerFactory.Object); + + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns(ApiUrl); + _configurationStore.Object.GetSettings().IsHostedServer = false; + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master.completed"); + Assert.True(File.Exists(watermarkFile)); + + var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master", "action.yml"); + Assert.True(File.Exists(actionYamlFile)); + _hc.GetTrace().Info(File.ReadAllText(actionYamlFile)); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DownloadUnknownActionFromGraph_OnPremises() + { + try + { + // Arrange + Setup(); + const string ActionName = "ownerName/sample-action"; + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = ActionName, + Ref = "master", + RepositoryType = "GitHub" + } + } + }; + + // Return a valid action from GHES via mock + const string ApiUrl = "https://ghes.example.com/api/v3"; + string archiveLink = GetLinkToActionArchive(ApiUrl, ActionName, "master"); + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + var mockClientHandler = new Mock(); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NotFound)); + + var mockHandlerFactory = new Mock(); + mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny())).Returns(mockClientHandler.Object); + _hc.SetSingleton(mockHandlerFactory.Object); + + _ec.Setup(x => x.GetGitHubContext("api_url")).Returns(ApiUrl); + _configurationStore.Object.GetSettings().IsHostedServer = false; + + //Act + Func action = async () => await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + await Assert.ThrowsAsync(action); + + var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master.completed"); + Assert.False(File.Exists(watermarkFile)); + + var actionYamlFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, "master", "action.yml"); + Assert.False(File.Exists(actionYamlFile)); } finally { @@ -862,7 +992,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -962,7 +1092,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1061,7 +1191,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1129,7 +1259,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1211,7 +1341,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1310,7 +1440,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1408,7 +1538,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1476,7 +1606,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1547,7 +1677,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'GitHub' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1647,7 +1777,7 @@ runs: name: 'Hello World' description: 'Greet the world and record the time' author: 'Test Corporation' -inputs: +inputs: greeting: # id of input description: 'The greeting we choose - will print ""{greeting}, World!"" on stdout' required: true @@ -1737,6 +1867,82 @@ runs: }; } + /// + /// Creates a sample action in an archive on disk, similar to the archive + /// retrieved from GitHub's or GHES' repository API. + /// + /// The path on disk to the archive. +#if OS_WINDOWS + private Task CreateRepoArchive() +#else + private async Task CreateRepoArchive() +#endif + { + const string Content = @" +# Container action +name: 'Hello World' +description: 'Greet the world' +author: 'GitHub' +icon: 'hello.svg' # vector art to display in the GitHub Marketplace +color: 'green' # optional, decorates the entry in the GitHub Marketplace +runs: + using: 'node12' + main: 'task.js' +"; + CreateAction(yamlContent: Content, instance: out _, directory: out string directory); + + var tempDir = _hc.GetDirectory(WellKnownDirectory.Temp); + Directory.CreateDirectory(tempDir); + var archiveFile = Path.Combine(tempDir, Path.GetRandomFileName()); + var trace = _hc.GetTrace(); + +#if OS_WINDOWS + ZipFile.CreateFromDirectory(directory, archiveFile, CompressionLevel.Fastest, includeBaseDirectory: true); + return Task.FromResult(archiveFile); +#else + string tar = WhichUtil.Which("tar", require: true, trace: trace); + + // tar -xzf + using (var processInvoker = new ProcessInvokerWrapper()) + { + processInvoker.Initialize(_hc); + processInvoker.OutputDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + trace.Info(args.Data); + } + }); + + processInvoker.ErrorDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + trace.Error(args.Data); + } + }); + + string cwd = Path.GetDirectoryName(directory); + string inputDirectory = Path.GetFileName(directory); + int exitCode = await processInvoker.ExecuteAsync(_hc.GetDirectory(WellKnownDirectory.Bin), tar, $"-czf \"{archiveFile}\" -C \"{cwd}\" \"{inputDirectory}\"", null, CancellationToken.None); + if (exitCode != 0) + { + throw new NotSupportedException($"Can't use 'tar -czf' to create archive file: {archiveFile}. return code: {exitCode}."); + } + } + return archiveFile; +#endif + } + + private static string GetLinkToActionArchive(string apiUrl, string repository, string @ref) + { +#if OS_WINDOWS + return $"{apiUrl}/repos/{repository}/zipball/{@ref}"; +#else + return $"{apiUrl}/repos/{repository}/tarball/{@ref}"; +#endif + } + private void Setup([CallerMemberName] string name = "") { _ecTokenSource?.Dispose(); @@ -1772,6 +1978,7 @@ runs: _hc.SetSingleton(_dockerManager.Object); _hc.SetSingleton(_pluginManager.Object); _hc.SetSingleton(actionManifest); + _hc.SetSingleton(new HttpClientHandlerFactory()); _configurationStore = new Mock(); _configurationStore