Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/Runner.Sdk/Util/UrlUtil.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Net.Http.Headers;
using GitHub.DistributedTask.WebApi;

namespace GitHub.Runner.Sdk
{
Expand All @@ -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));
Expand Down
22 changes: 20 additions & 2 deletions src/Runner.Worker/ActionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IConfigurationStore>();
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))
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method IsGHECDRFallbackToDotcom is called with a potentially null gitHubUrl parameter. This will cause a null reference exception if both serverUrl and runnerSettings.GitHubUrl are null or empty.

Suggested change
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo))
if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token) && (gitHubUrl == null || !UrlUtil.IsGHECDRFallbackToDotcom(gitHubUrl, actionDownloadInfo)))

Copilot uses AI. Check for mistakes.
{
actionDownloadInfo.Authentication = new WebApi.ActionDownloadAuthentication { Token = defaultAccessToken };
}
Expand Down
82 changes: 82 additions & 0 deletions src/Test/L0/Worker/ActionManagerL0.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Guid>(), It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<ActionReferenceList>(), It.IsAny<CancellationToken>()))
.Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) =>
{
var result = new ActionDownloadInfoCollection { Actions = new Dictionary<string, ActionDownloadInfo>() };
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<HttpClientHandler>();
string downloadUrl = string.Format(downloadUrlTemplate, ActionName, ActionRef);
mockClientHandler.Protected().Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.Is<HttpRequestMessage>(m => m.RequestUri == new Uri(downloadUrl)), ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream) });

var mockHandlerFactory = new Mock<IHttpClientHandlerFactory>();
mockHandlerFactory.Setup(p => p.CreateClientHandler(It.IsAny<RunnerWebProxy>())).Returns(mockClientHandler.Object);
_hc.SetSingleton(mockHandlerFactory.Object);

await _actionManager.PrepareActionsAsync(_ec.Object, new List<Pipelines.JobStep> { 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");
Expand Down
Loading