Add configurable timeout and retry for git network operations

Add per-attempt timeout (default 300s) and Kubernetes probe-style retry
configuration for git fetch, lfs-fetch, and ls-remote. New action inputs:
timeout, retry-max-attempts, retry-min-backoff, retry-max-backoff.

Fixes https://github.com/actions/checkout/issues/631

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anatoly Rabkin 2026-03-18 18:06:25 +02:00
parent 0c366fd6a8
commit 5df58a66d1
10 changed files with 342 additions and 81 deletions

91
dist/index.js vendored
View file

@ -678,6 +678,8 @@ class GitCommandManager {
this.doSparseCheckout = false;
this.workingDirectory = '';
this.gitVersion = new git_version_1.GitVersion();
this.timeoutMs = 0;
this.networkRetryHelper = new retryHelper.RetryHelper();
}
branchDelete(remote, branch) {
return __awaiter(this, void 0, void 0, function* () {
@ -851,15 +853,15 @@ class GitCommandManager {
args.push(arg);
}
const that = this;
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args);
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args, false, false, {}, that.timeoutMs);
}));
});
}
getDefaultBranch(repositoryUrl) {
return __awaiter(this, void 0, void 0, function* () {
let output;
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
output = yield this.execGit([
'ls-remote',
'--quiet',
@ -867,7 +869,7 @@ class GitCommandManager {
'--symref',
repositoryUrl,
'HEAD'
]);
], false, false, {}, this.timeoutMs);
}));
if (output) {
// Satisfy compiler, will always be set
@ -912,8 +914,8 @@ class GitCommandManager {
return __awaiter(this, void 0, void 0, function* () {
const args = ['lfs', 'fetch', 'origin', ref];
const that = this;
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args);
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args, false, false, {}, that.timeoutMs);
}));
});
}
@ -1107,6 +1109,12 @@ class GitCommandManager {
return this.gitVersion;
});
}
setTimeout(timeoutSeconds) {
this.timeoutMs = timeoutSeconds * 1000;
}
setRetryConfig(maxAttempts, minBackoffSeconds, maxBackoffSeconds) {
this.networkRetryHelper = new retryHelper.RetryHelper(maxAttempts, minBackoffSeconds, maxBackoffSeconds);
}
static createCommandManager(workingDirectory, lfs, doSparseCheckout) {
return __awaiter(this, void 0, void 0, function* () {
const result = new GitCommandManager();
@ -1115,7 +1123,7 @@ class GitCommandManager {
});
}
execGit(args_1) {
return __awaiter(this, arguments, void 0, function* (args, allowAllExitCodes = false, silent = false, customListeners = {}) {
return __awaiter(this, arguments, void 0, function* (args, allowAllExitCodes = false, silent = false, customListeners = {}, timeoutMs = 0) {
fshelper.directoryExistsSync(this.workingDirectory, true);
const result = new GitOutput();
const env = {};
@ -1139,7 +1147,24 @@ class GitCommandManager {
ignoreReturnCode: allowAllExitCodes,
listeners: mergedListeners
};
result.exitCode = yield exec.exec(`"${this.gitPath}"`, args, options);
const execPromise = exec.exec(`"${this.gitPath}"`, args, options);
if (timeoutMs > 0) {
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = global.setTimeout(() => {
reject(new Error(`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 3).join(' ')}...`));
}, timeoutMs);
});
try {
result.exitCode = yield Promise.race([execPromise, timeoutPromise]);
}
finally {
clearTimeout(timer);
}
}
else {
result.exitCode = yield execPromise;
}
result.stdout = stdout.join('');
core.debug(result.exitCode.toString());
core.debug(result.stdout);
@ -1448,6 +1473,10 @@ function getSource(settings) {
core.startGroup('Getting Git version info');
const git = yield getGitCommandManager(settings);
core.endGroup();
if (git) {
git.setTimeout(settings.timeout);
git.setRetryConfig(settings.retryMaxAttempts, settings.retryMinBackoff, settings.retryMaxBackoff);
}
let authHelper = null;
try {
if (git) {
@ -2095,6 +2124,32 @@ function getInputs() {
// Determine the GitHub URL that the repository is being hosted from
result.githubServerUrl = core.getInput('github-server-url');
core.debug(`GitHub Host URL = ${result.githubServerUrl}`);
// Timeout (per-attempt, like k8s timeoutSeconds)
result.timeout = Math.floor(Number(core.getInput('timeout') || '300'));
if (isNaN(result.timeout) || result.timeout < 0) {
result.timeout = 300;
}
core.debug(`timeout = ${result.timeout}`);
// Retry max attempts (like k8s failureThreshold)
result.retryMaxAttempts = Math.floor(Number(core.getInput('retry-max-attempts') || '3'));
if (isNaN(result.retryMaxAttempts) || result.retryMaxAttempts < 1) {
result.retryMaxAttempts = 3;
}
core.debug(`retry max attempts = ${result.retryMaxAttempts}`);
// Retry backoff (like k8s periodSeconds, but as a min/max range)
result.retryMinBackoff = Math.floor(Number(core.getInput('retry-min-backoff') || '10'));
if (isNaN(result.retryMinBackoff) || result.retryMinBackoff < 0) {
result.retryMinBackoff = 10;
}
core.debug(`retry min backoff = ${result.retryMinBackoff}`);
result.retryMaxBackoff = Math.floor(Number(core.getInput('retry-max-backoff') || '20'));
if (isNaN(result.retryMaxBackoff) || result.retryMaxBackoff < 0) {
result.retryMaxBackoff = 20;
}
if (result.retryMaxBackoff < result.retryMinBackoff) {
result.retryMaxBackoff = result.retryMinBackoff;
}
core.debug(`retry max backoff = ${result.retryMaxBackoff}`);
return result;
});
}
@ -5260,6 +5315,7 @@ class Context {
this.action = process.env.GITHUB_ACTION;
this.actor = process.env.GITHUB_ACTOR;
this.job = process.env.GITHUB_JOB;
this.runAttempt = parseInt(process.env.GITHUB_RUN_ATTEMPT, 10);
this.runNumber = parseInt(process.env.GITHUB_RUN_NUMBER, 10);
this.runId = parseInt(process.env.GITHUB_RUN_ID, 10);
this.apiUrl = (_a = process.env.GITHUB_API_URL) !== null && _a !== void 0 ? _a : `https://api.github.com`;
@ -6136,7 +6192,7 @@ class HttpClient {
}
const usingSsl = parsedUrl.protocol === 'https:';
proxyAgent = new undici_1.ProxyAgent(Object.assign({ uri: proxyUrl.href, pipelining: !this._keepAlive ? 0 : 1 }, ((proxyUrl.username || proxyUrl.password) && {
token: `${proxyUrl.username}:${proxyUrl.password}`
token: `Basic ${Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`).toString('base64')}`
})));
this._proxyAgentDispatcher = proxyAgent;
if (usingSsl && this._ignoreSslError) {
@ -6250,11 +6306,11 @@ function getProxyUrl(reqUrl) {
})();
if (proxyVar) {
try {
return new URL(proxyVar);
return new DecodedURL(proxyVar);
}
catch (_a) {
if (!proxyVar.startsWith('http://') && !proxyVar.startsWith('https://'))
return new URL(`http://${proxyVar}`);
return new DecodedURL(`http://${proxyVar}`);
}
}
else {
@ -6313,6 +6369,19 @@ function isLoopbackAddress(host) {
hostLower.startsWith('[::1]') ||
hostLower.startsWith('[0:0:0:0:0:0:0:1]'));
}
class DecodedURL extends URL {
constructor(url, base) {
super(url, base);
this._decodedUsername = decodeURIComponent(super.username);
this._decodedPassword = decodeURIComponent(super.password);
}
get username() {
return this._decodedUsername;
}
get password() {
return this._decodedPassword;
}
}
//# sourceMappingURL=proxy.js.map
/***/ }),