diff --git a/src/Runner.Sdk/Util/UrlUtil.cs b/src/Runner.Sdk/Util/UrlUtil.cs index 52ce3a0cbf9..60dcb21dd97 100644 --- a/src/Runner.Sdk/Util/UrlUtil.cs +++ b/src/Runner.Sdk/Util/UrlUtil.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net.Http.Headers; +using GitHub.DistributedTask.WebApi; namespace GitHub.Runner.Sdk { @@ -21,6 +22,52 @@ public static bool IsHostedServer(UriBuilder gitHubUrl) gitHubUrl.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase); } + // For GitHub Enterprise Cloud with data residency, we allow fallback to GitHub.com for Actions resolution + public static bool IsGHECDRFallbackToDotcom(UriBuilder gitHubUrl, ActionDownloadInfo downloadInfo) + { + if (gitHubUrl == null || downloadInfo == null) + { + return false; + } + +#if OS_WINDOWS + var downloadUrl = downloadInfo.ZipballUrl; +#else + var downloadUrl = downloadInfo.TarballUrl; +#endif + + if (string.IsNullOrEmpty(downloadUrl)) + { + return false; + } + + try + { + var downloadUriBuilder = new UriBuilder(downloadUrl); + if (!string.Equals(downloadUriBuilder.Host, "api.github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Check if the path follows the expected pattern: /repos/{owner}/{repo}/(tar|zip)ball/{ref} + var pathSegments = downloadUriBuilder.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathSegments.Length < 5 || + !string.Equals(pathSegments[0], "repos", StringComparison.OrdinalIgnoreCase) || + (!string.Equals(pathSegments[3], "tarball", StringComparison.OrdinalIgnoreCase) && + !string.Equals(pathSegments[3], "zipball", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + catch (UriFormatException) + { + return false; + } + + return gitHubUrl.Host.EndsWith(".ghe.localhost", StringComparison.OrdinalIgnoreCase) || + gitHubUrl.Host.EndsWith(".ghe.com", StringComparison.OrdinalIgnoreCase); + } + public static Uri GetCredentialEmbeddedUrl(Uri baseUrl, string username, string password) { ArgUtil.NotNull(baseUrl, nameof(baseUrl)); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 9a21aeb4cb0..ec9325e601f 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -739,13 +739,31 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, ArgUtil.NotNull(actionDownloadInfos.Actions, nameof(actionDownloadInfos.Actions)); var defaultAccessToken = executionContext.GetGitHubContext("token"); + // Get GitHub URL for OnPrem fallback logic + UriBuilder gitHubUrl = null; + var serverUrl = executionContext.GetGitHubContext("server_url"); + if (!string.IsNullOrEmpty(serverUrl)) + { + gitHubUrl = new UriBuilder(serverUrl); + } + else + { + // Fallback to runner settings if GitHub context doesn't have server_url + var configurationStore = HostContext.GetService(); + var runnerSettings = configurationStore.GetSettings(); + if (!string.IsNullOrEmpty(runnerSettings.GitHubUrl)) + { + gitHubUrl = new UriBuilder(runnerSettings.GitHubUrl); + } + } + foreach (var actionDownloadInfo in actionDownloadInfos.Actions.Values) { // Add secret HostContext.SecretMasker.AddValue(actionDownloadInfo.Authentication?.Token); - // Default auth token - if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token)) + // Use default auth token unless falling back from OnPrem + if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo)) { actionDownloadInfo.Authentication = new WebApi.ActionDownloadAuthentication { Token = defaultAccessToken }; } diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 328c5b5f61b..507cbb88f3c 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -2315,6 +2315,88 @@ public void LoadsPluginActionDefinition() } } + [Theory] + [InlineData("https://company.ghe.com", "https://api.github.com/repos/{0}/tarball/{1}", false, "GHEC OnPrem fallback to dotcom - skips default token")] + [InlineData("https://ghes.company.com", "https://ghes.company.com/api/v3/repos/{0}/tarball/{1}", true, "Regular GHES - uses default token")] + [InlineData("https://company.ghe.localhost", "https://api.github.com/repos/{0}/tarball/{1}", false, "(tarball) GHEC OnPrem localhost fallback to dotcom - skips default token")] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void GetDownloadInfoAsync_DefaultTokenBehavior_BasedOnFallbackScenario(string serverUrl, string downloadUrlTemplate, bool shouldUseDefaultToken, string scenario) + { + try + { + Setup(); + const string ActionName = "actions/checkout"; + const string ActionRef = "v3"; + var actions = new Pipelines.ActionStep() + { + Name = "action", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = ActionName, + Ref = ActionRef, + RepositoryType = "GitHub" + } + }; + + _ec.Setup(x => x.GetGitHubContext("server_url")).Returns(serverUrl); + _ec.Setup(x => x.GetGitHubContext("token")).Returns("default-token"); + + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = string.Format(downloadUrlTemplate, action.NameWithOwner, action.Ref), + ZipballUrl = string.Format(downloadUrlTemplate.Replace("tarball", "zipball"), action.NameWithOwner, action.Ref), + Authentication = null // No token set - will be tested for default token behavior + }; + } + return Task.FromResult(result); + }); + + string archiveFile = await CreateRepoArchive(); + using var stream = File.OpenRead(archiveFile); + var mockClientHandler = new Mock(); + string downloadUrl = string.Format(downloadUrlTemplate, ActionName, ActionRef); + mockClientHandler.Protected().Setup>("SendAsync", ItExpr.Is(m => m.RequestUri == new Uri(downloadUrl)), 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); + + await _actionManager.PrepareActionsAsync(_ec.Object, new List { actions }); + + var watermarkFile = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), ActionName, $"{ActionRef}.completed"); + Assert.True(File.Exists(watermarkFile), $"Failed scenario: {scenario}"); + + if (shouldUseDefaultToken) + { + // For regular GHES, the default token should be used + _ec.Verify(x => x.GetGitHubContext("token"), Times.AtLeastOnce); + } + else + { + // For GHEC OnPrem fallback scenarios, test that the download succeeded without using default token + Assert.True(File.Exists(watermarkFile), $"GHEC OnPrem fallback scenario should succeed: {scenario}"); + } + } + finally + { + Teardown(); + } + } + private void CreateAction(string yamlContent, out Pipelines.ActionStep instance, out string directory) { directory = Path.Combine(_workFolder, Constants.Path.ActionsDirectory, "GitHub/actions".Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), "main");