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

View file

@ -155,6 +155,28 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# Default: true # Default: true
set-safe-directory: '' set-safe-directory: ''
# Timeout in seconds for each git network operation attempt (fetch, lfs-fetch,
# ls-remote). If a single attempt exceeds this, it is killed and retried. Set to 0
# to disable. Default is 300 (5 minutes). Similar to Kubernetes probe
# timeoutSeconds.
# Default: 300
timeout: ''
# Maximum number of retry attempts for failed git network operations. Similar to
# Kubernetes probe failureThreshold.
# Default: 3
retry-max-attempts: ''
# Minimum backoff time in seconds between retry attempts. The actual backoff is
# randomly chosen between min and max. Similar to Kubernetes probe periodSeconds.
# Default: 10
retry-min-backoff: ''
# Maximum backoff time in seconds between retry attempts. The actual backoff is
# randomly chosen between min and max.
# Default: 20
retry-max-backoff: ''
# The base URL for the GitHub instance that you are trying to clone from, will use # The base URL for the GitHub instance that you are trying to clone from, will use
# environment defaults to fetch from the same instance that the workflow is # environment defaults to fetch from the same instance that the workflow is
# running from unless specified. Example URLs are https://github.com or # running from unless specified. Example URLs are https://github.com or

View file

@ -1146,7 +1146,9 @@ async function setup(testName: string): Promise<void> {
} }
), ),
tryReset: jest.fn(), tryReset: jest.fn(),
version: jest.fn() version: jest.fn(),
setTimeout: jest.fn(),
setRetryConfig: jest.fn()
} }
settings = { settings = {
@ -1173,7 +1175,11 @@ async function setup(testName: string): Promise<void> {
sshUser: '', sshUser: '',
workflowOrganizationId: 123456, workflowOrganizationId: 123456,
setSafeDirectory: true, setSafeDirectory: true,
githubServerUrl: githubServerUrl githubServerUrl: githubServerUrl,
timeout: 300,
retryMaxAttempts: 3,
retryMinBackoff: 10,
retryMaxBackoff: 20
} }
} }

View file

@ -506,6 +506,8 @@ async function setup(testName: string): Promise<void> {
tryReset: jest.fn(async () => { tryReset: jest.fn(async () => {
return true return true
}), }),
version: jest.fn() version: jest.fn(),
setTimeout: jest.fn(),
setRetryConfig: jest.fn()
} }
} }

View file

@ -95,6 +95,29 @@ inputs:
set-safe-directory: set-safe-directory:
description: Add repository path as safe.directory for Git global config by running `git config --global --add safe.directory <path>` description: Add repository path as safe.directory for Git global config by running `git config --global --add safe.directory <path>`
default: true default: true
timeout:
description: >
Timeout in seconds for each git network operation attempt (fetch, lfs-fetch, ls-remote).
If a single attempt exceeds this, it is killed and retried.
Set to 0 to disable. Default is 300 (5 minutes).
Similar to Kubernetes probe timeoutSeconds.
default: 300
retry-max-attempts:
description: >
Maximum number of retry attempts for failed git network operations.
Similar to Kubernetes probe failureThreshold.
default: 3
retry-min-backoff:
description: >
Minimum backoff time in seconds between retry attempts.
The actual backoff is randomly chosen between min and max.
Similar to Kubernetes probe periodSeconds.
default: 10
retry-max-backoff:
description: >
Maximum backoff time in seconds between retry attempts.
The actual backoff is randomly chosen between min and max.
default: 20
github-server-url: github-server-url:
description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com
required: false required: false

91
dist/index.js vendored
View file

@ -678,6 +678,8 @@ class GitCommandManager {
this.doSparseCheckout = false; this.doSparseCheckout = false;
this.workingDirectory = ''; this.workingDirectory = '';
this.gitVersion = new git_version_1.GitVersion(); this.gitVersion = new git_version_1.GitVersion();
this.timeoutMs = 0;
this.networkRetryHelper = new retryHelper.RetryHelper();
} }
branchDelete(remote, branch) { branchDelete(remote, branch) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
@ -851,15 +853,15 @@ class GitCommandManager {
args.push(arg); args.push(arg);
} }
const that = this; const that = this;
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args); yield that.execGit(args, false, false, {}, that.timeoutMs);
})); }));
}); });
} }
getDefaultBranch(repositoryUrl) { getDefaultBranch(repositoryUrl) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
let output; 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([ output = yield this.execGit([
'ls-remote', 'ls-remote',
'--quiet', '--quiet',
@ -867,7 +869,7 @@ class GitCommandManager {
'--symref', '--symref',
repositoryUrl, repositoryUrl,
'HEAD' 'HEAD'
]); ], false, false, {}, this.timeoutMs);
})); }));
if (output) { if (output) {
// Satisfy compiler, will always be set // Satisfy compiler, will always be set
@ -912,8 +914,8 @@ class GitCommandManager {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const args = ['lfs', 'fetch', 'origin', ref]; const args = ['lfs', 'fetch', 'origin', ref];
const that = this; const that = this;
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
yield that.execGit(args); yield that.execGit(args, false, false, {}, that.timeoutMs);
})); }));
}); });
} }
@ -1107,6 +1109,12 @@ class GitCommandManager {
return this.gitVersion; 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) { static createCommandManager(workingDirectory, lfs, doSparseCheckout) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const result = new GitCommandManager(); const result = new GitCommandManager();
@ -1115,7 +1123,7 @@ class GitCommandManager {
}); });
} }
execGit(args_1) { 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); fshelper.directoryExistsSync(this.workingDirectory, true);
const result = new GitOutput(); const result = new GitOutput();
const env = {}; const env = {};
@ -1139,7 +1147,24 @@ class GitCommandManager {
ignoreReturnCode: allowAllExitCodes, ignoreReturnCode: allowAllExitCodes,
listeners: mergedListeners 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(''); result.stdout = stdout.join('');
core.debug(result.exitCode.toString()); core.debug(result.exitCode.toString());
core.debug(result.stdout); core.debug(result.stdout);
@ -1448,6 +1473,10 @@ function getSource(settings) {
core.startGroup('Getting Git version info'); core.startGroup('Getting Git version info');
const git = yield getGitCommandManager(settings); const git = yield getGitCommandManager(settings);
core.endGroup(); core.endGroup();
if (git) {
git.setTimeout(settings.timeout);
git.setRetryConfig(settings.retryMaxAttempts, settings.retryMinBackoff, settings.retryMaxBackoff);
}
let authHelper = null; let authHelper = null;
try { try {
if (git) { if (git) {
@ -2095,6 +2124,32 @@ function getInputs() {
// Determine the GitHub URL that the repository is being hosted from // Determine the GitHub URL that the repository is being hosted from
result.githubServerUrl = core.getInput('github-server-url'); result.githubServerUrl = core.getInput('github-server-url');
core.debug(`GitHub Host URL = ${result.githubServerUrl}`); 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; return result;
}); });
} }
@ -5260,6 +5315,7 @@ class Context {
this.action = process.env.GITHUB_ACTION; this.action = process.env.GITHUB_ACTION;
this.actor = process.env.GITHUB_ACTOR; this.actor = process.env.GITHUB_ACTOR;
this.job = process.env.GITHUB_JOB; 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.runNumber = parseInt(process.env.GITHUB_RUN_NUMBER, 10);
this.runId = parseInt(process.env.GITHUB_RUN_ID, 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`; 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:'; const usingSsl = parsedUrl.protocol === 'https:';
proxyAgent = new undici_1.ProxyAgent(Object.assign({ uri: proxyUrl.href, pipelining: !this._keepAlive ? 0 : 1 }, ((proxyUrl.username || proxyUrl.password) && { 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; this._proxyAgentDispatcher = proxyAgent;
if (usingSsl && this._ignoreSslError) { if (usingSsl && this._ignoreSslError) {
@ -6250,11 +6306,11 @@ function getProxyUrl(reqUrl) {
})(); })();
if (proxyVar) { if (proxyVar) {
try { try {
return new URL(proxyVar); return new DecodedURL(proxyVar);
} }
catch (_a) { catch (_a) {
if (!proxyVar.startsWith('http://') && !proxyVar.startsWith('https://')) if (!proxyVar.startsWith('http://') && !proxyVar.startsWith('https://'))
return new URL(`http://${proxyVar}`); return new DecodedURL(`http://${proxyVar}`);
} }
} }
else { else {
@ -6313,6 +6369,19 @@ function isLoopbackAddress(host) {
hostLower.startsWith('[::1]') || hostLower.startsWith('[::1]') ||
hostLower.startsWith('[0:0:0:0:0:0:0: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 //# sourceMappingURL=proxy.js.map
/***/ }), /***/ }),

123
package-lock.json generated
View file

@ -69,20 +69,25 @@
} }
}, },
"node_modules/@actions/github": { "node_modules/@actions/github": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz",
"integrity": "sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==", "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@actions/http-client": "^2.2.0", "@actions/http-client": "^2.2.0",
"@octokit/core": "^5.0.1", "@octokit/core": "^5.0.1",
"@octokit/plugin-paginate-rest": "^9.0.0", "@octokit/plugin-paginate-rest": "^9.2.2",
"@octokit/plugin-rest-endpoint-methods": "^10.0.0" "@octokit/plugin-rest-endpoint-methods": "^10.4.0",
"@octokit/request": "^8.4.1",
"@octokit/request-error": "^5.1.1",
"undici": "^5.28.5"
} }
}, },
"node_modules/@actions/http-client": { "node_modules/@actions/http-client": {
"version": "2.2.1", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
"integrity": "sha512-KhC/cZsq7f8I4LfZSJKgCvEwfkE8o1538VoBeoGzokVLLnbFDEAdFD3UhoMklxo2un9NJVBdANOresx7vTHlHw==", "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
"license": "MIT",
"dependencies": { "dependencies": {
"tunnel": "^0.0.6", "tunnel": "^0.0.6",
"undici": "^5.25.4" "undici": "^5.25.4"
@ -681,10 +686,11 @@
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/minimatch": { "node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -741,10 +747,11 @@
} }
}, },
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": { "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -810,10 +817,11 @@
} }
}, },
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.1", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@ -1784,10 +1792,11 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@ -3136,10 +3145,11 @@
} }
}, },
"node_modules/eslint-plugin-import/node_modules/minimatch": { "node_modules/eslint-plugin-import/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -3214,10 +3224,11 @@
} }
}, },
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -3313,10 +3324,11 @@
} }
}, },
"node_modules/eslint/node_modules/minimatch": { "node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -3536,10 +3548,11 @@
} }
}, },
"node_modules/filelist/node_modules/minimatch": { "node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
}, },
@ -3590,10 +3603,11 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true "dev": true,
"license": "ISC"
}, },
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
@ -3779,10 +3793,11 @@
} }
}, },
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -4579,10 +4594,11 @@
} }
}, },
"node_modules/jake/node_modules/minimatch": { "node_modules/jake/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -5186,10 +5202,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@ -5486,12 +5503,13 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.4", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@ -6564,10 +6582,11 @@
} }
}, },
"node_modules/test-exclude/node_modules/minimatch": { "node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },

View file

@ -80,6 +80,12 @@ export interface IGitCommandManager {
): Promise<string[]> ): Promise<string[]>
tryReset(): Promise<boolean> tryReset(): Promise<boolean>
version(): Promise<GitVersion> version(): Promise<GitVersion>
setTimeout(timeoutSeconds: number): void
setRetryConfig(
maxAttempts: number,
minBackoffSeconds: number,
maxBackoffSeconds: number
): void
} }
export async function createCommandManager( export async function createCommandManager(
@ -104,6 +110,8 @@ class GitCommandManager {
private doSparseCheckout = false private doSparseCheckout = false
private workingDirectory = '' private workingDirectory = ''
private gitVersion: GitVersion = new GitVersion() private gitVersion: GitVersion = new GitVersion()
private timeoutMs = 0
private networkRetryHelper = new retryHelper.RetryHelper()
// Private constructor; use createCommandManager() // Private constructor; use createCommandManager()
private constructor() {} private constructor() {}
@ -312,22 +320,28 @@ class GitCommandManager {
} }
const that = this const that = this
await retryHelper.execute(async () => { await this.networkRetryHelper.execute(async () => {
await that.execGit(args) await that.execGit(args, false, false, {}, that.timeoutMs)
}) })
} }
async getDefaultBranch(repositoryUrl: string): Promise<string> { async getDefaultBranch(repositoryUrl: string): Promise<string> {
let output: GitOutput | undefined let output: GitOutput | undefined
await retryHelper.execute(async () => { await this.networkRetryHelper.execute(async () => {
output = await this.execGit([ output = await this.execGit(
[
'ls-remote', 'ls-remote',
'--quiet', '--quiet',
'--exit-code', '--exit-code',
'--symref', '--symref',
repositoryUrl, repositoryUrl,
'HEAD' 'HEAD'
]) ],
false,
false,
{},
this.timeoutMs
)
}) })
if (output) { if (output) {
@ -381,8 +395,8 @@ class GitCommandManager {
const args = ['lfs', 'fetch', 'origin', ref] const args = ['lfs', 'fetch', 'origin', ref]
const that = this const that = this
await retryHelper.execute(async () => { await this.networkRetryHelper.execute(async () => {
await that.execGit(args) await that.execGit(args, false, false, {}, that.timeoutMs)
}) })
} }
@ -595,6 +609,22 @@ class GitCommandManager {
return this.gitVersion return this.gitVersion
} }
setTimeout(timeoutSeconds: number): void {
this.timeoutMs = timeoutSeconds * 1000
}
setRetryConfig(
maxAttempts: number,
minBackoffSeconds: number,
maxBackoffSeconds: number
): void {
this.networkRetryHelper = new retryHelper.RetryHelper(
maxAttempts,
minBackoffSeconds,
maxBackoffSeconds
)
}
static async createCommandManager( static async createCommandManager(
workingDirectory: string, workingDirectory: string,
lfs: boolean, lfs: boolean,
@ -613,7 +643,8 @@ class GitCommandManager {
args: string[], args: string[],
allowAllExitCodes = false, allowAllExitCodes = false,
silent = false, silent = false,
customListeners = {} customListeners = {},
timeoutMs = 0
): Promise<GitOutput> { ): Promise<GitOutput> {
fshelper.directoryExistsSync(this.workingDirectory, true) fshelper.directoryExistsSync(this.workingDirectory, true)
@ -644,7 +675,28 @@ class GitCommandManager {
listeners: mergedListeners listeners: mergedListeners
} }
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options) const execPromise = exec.exec(`"${this.gitPath}"`, args, options)
if (timeoutMs > 0) {
let timer: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, 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 = await Promise.race([execPromise, timeoutPromise])
} finally {
clearTimeout(timer!)
}
} else {
result.exitCode = await execPromise
}
result.stdout = stdout.join('') result.stdout = stdout.join('')
core.debug(result.exitCode.toString()) core.debug(result.exitCode.toString())

View file

@ -39,6 +39,15 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
const git = await getGitCommandManager(settings) const git = await getGitCommandManager(settings)
core.endGroup() core.endGroup()
if (git) {
git.setTimeout(settings.timeout)
git.setRetryConfig(
settings.retryMaxAttempts,
settings.retryMinBackoff,
settings.retryMaxBackoff
)
}
let authHelper: gitAuthHelper.IGitAuthHelper | null = null let authHelper: gitAuthHelper.IGitAuthHelper | null = null
try { try {
if (git) { if (git) {

View file

@ -118,4 +118,27 @@ export interface IGitSourceSettings {
* User override on the GitHub Server/Host URL that hosts the repository to be cloned * User override on the GitHub Server/Host URL that hosts the repository to be cloned
*/ */
githubServerUrl: string | undefined githubServerUrl: string | undefined
/**
* Timeout in seconds for each network git operation attempt (fetch, lfs-fetch, ls-remote).
* 0 means no timeout. Similar to Kubernetes probe timeoutSeconds.
*/
timeout: number
/**
* Maximum number of retry attempts for failed network git operations.
* Similar to Kubernetes probe failureThreshold.
*/
retryMaxAttempts: number
/**
* Minimum backoff time in seconds between retry attempts.
* Similar to Kubernetes probe periodSeconds.
*/
retryMinBackoff: number
/**
* Maximum backoff time in seconds between retry attempts.
*/
retryMaxBackoff: number
} }

View file

@ -161,5 +161,41 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.githubServerUrl = core.getInput('github-server-url') result.githubServerUrl = core.getInput('github-server-url')
core.debug(`GitHub Host URL = ${result.githubServerUrl}`) 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 return result
} }