Skip to content

Commit 5df58a6

Browse files
arabkinclaude
andcommitted
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 #631 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0c366fd commit 5df58a6

File tree

10 files changed

+342
-81
lines changed

10 files changed

+342
-81
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
155155
# Default: true
156156
set-safe-directory: ''
157157

158+
# Timeout in seconds for each git network operation attempt (fetch, lfs-fetch,
159+
# ls-remote). If a single attempt exceeds this, it is killed and retried. Set to 0
160+
# to disable. Default is 300 (5 minutes). Similar to Kubernetes probe
161+
# timeoutSeconds.
162+
# Default: 300
163+
timeout: ''
164+
165+
# Maximum number of retry attempts for failed git network operations. Similar to
166+
# Kubernetes probe failureThreshold.
167+
# Default: 3
168+
retry-max-attempts: ''
169+
170+
# Minimum backoff time in seconds between retry attempts. The actual backoff is
171+
# randomly chosen between min and max. Similar to Kubernetes probe periodSeconds.
172+
# Default: 10
173+
retry-min-backoff: ''
174+
175+
# Maximum backoff time in seconds between retry attempts. The actual backoff is
176+
# randomly chosen between min and max.
177+
# Default: 20
178+
retry-max-backoff: ''
179+
158180
# The base URL for the GitHub instance that you are trying to clone from, will use
159181
# environment defaults to fetch from the same instance that the workflow is
160182
# running from unless specified. Example URLs are https://github.com or

__test__/git-auth-helper.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,7 +1146,9 @@ async function setup(testName: string): Promise<void> {
11461146
}
11471147
),
11481148
tryReset: jest.fn(),
1149-
version: jest.fn()
1149+
version: jest.fn(),
1150+
setTimeout: jest.fn(),
1151+
setRetryConfig: jest.fn()
11501152
}
11511153

11521154
settings = {
@@ -1173,7 +1175,11 @@ async function setup(testName: string): Promise<void> {
11731175
sshUser: '',
11741176
workflowOrganizationId: 123456,
11751177
setSafeDirectory: true,
1176-
githubServerUrl: githubServerUrl
1178+
githubServerUrl: githubServerUrl,
1179+
timeout: 300,
1180+
retryMaxAttempts: 3,
1181+
retryMinBackoff: 10,
1182+
retryMaxBackoff: 20
11771183
}
11781184
}
11791185

__test__/git-directory-helper.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,8 @@ async function setup(testName: string): Promise<void> {
506506
tryReset: jest.fn(async () => {
507507
return true
508508
}),
509-
version: jest.fn()
509+
version: jest.fn(),
510+
setTimeout: jest.fn(),
511+
setRetryConfig: jest.fn()
510512
}
511513
}

action.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ inputs:
9595
set-safe-directory:
9696
description: Add repository path as safe.directory for Git global config by running `git config --global --add safe.directory <path>`
9797
default: true
98+
timeout:
99+
description: >
100+
Timeout in seconds for each git network operation attempt (fetch, lfs-fetch, ls-remote).
101+
If a single attempt exceeds this, it is killed and retried.
102+
Set to 0 to disable. Default is 300 (5 minutes).
103+
Similar to Kubernetes probe timeoutSeconds.
104+
default: 300
105+
retry-max-attempts:
106+
description: >
107+
Maximum number of retry attempts for failed git network operations.
108+
Similar to Kubernetes probe failureThreshold.
109+
default: 3
110+
retry-min-backoff:
111+
description: >
112+
Minimum backoff time in seconds between retry attempts.
113+
The actual backoff is randomly chosen between min and max.
114+
Similar to Kubernetes probe periodSeconds.
115+
default: 10
116+
retry-max-backoff:
117+
description: >
118+
Maximum backoff time in seconds between retry attempts.
119+
The actual backoff is randomly chosen between min and max.
120+
default: 20
98121
github-server-url:
99122
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
100123
required: false

dist/index.js

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,8 @@ class GitCommandManager {
678678
this.doSparseCheckout = false;
679679
this.workingDirectory = '';
680680
this.gitVersion = new git_version_1.GitVersion();
681+
this.timeoutMs = 0;
682+
this.networkRetryHelper = new retryHelper.RetryHelper();
681683
}
682684
branchDelete(remote, branch) {
683685
return __awaiter(this, void 0, void 0, function* () {
@@ -851,23 +853,23 @@ class GitCommandManager {
851853
args.push(arg);
852854
}
853855
const that = this;
854-
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
855-
yield that.execGit(args);
856+
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
857+
yield that.execGit(args, false, false, {}, that.timeoutMs);
856858
}));
857859
});
858860
}
859861
getDefaultBranch(repositoryUrl) {
860862
return __awaiter(this, void 0, void 0, function* () {
861863
let output;
862-
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
864+
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
863865
output = yield this.execGit([
864866
'ls-remote',
865867
'--quiet',
866868
'--exit-code',
867869
'--symref',
868870
repositoryUrl,
869871
'HEAD'
870-
]);
872+
], false, false, {}, this.timeoutMs);
871873
}));
872874
if (output) {
873875
// Satisfy compiler, will always be set
@@ -912,8 +914,8 @@ class GitCommandManager {
912914
return __awaiter(this, void 0, void 0, function* () {
913915
const args = ['lfs', 'fetch', 'origin', ref];
914916
const that = this;
915-
yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
916-
yield that.execGit(args);
917+
yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () {
918+
yield that.execGit(args, false, false, {}, that.timeoutMs);
917919
}));
918920
});
919921
}
@@ -1107,6 +1109,12 @@ class GitCommandManager {
11071109
return this.gitVersion;
11081110
});
11091111
}
1112+
setTimeout(timeoutSeconds) {
1113+
this.timeoutMs = timeoutSeconds * 1000;
1114+
}
1115+
setRetryConfig(maxAttempts, minBackoffSeconds, maxBackoffSeconds) {
1116+
this.networkRetryHelper = new retryHelper.RetryHelper(maxAttempts, minBackoffSeconds, maxBackoffSeconds);
1117+
}
11101118
static createCommandManager(workingDirectory, lfs, doSparseCheckout) {
11111119
return __awaiter(this, void 0, void 0, function* () {
11121120
const result = new GitCommandManager();
@@ -1115,7 +1123,7 @@ class GitCommandManager {
11151123
});
11161124
}
11171125
execGit(args_1) {
1118-
return __awaiter(this, arguments, void 0, function* (args, allowAllExitCodes = false, silent = false, customListeners = {}) {
1126+
return __awaiter(this, arguments, void 0, function* (args, allowAllExitCodes = false, silent = false, customListeners = {}, timeoutMs = 0) {
11191127
fshelper.directoryExistsSync(this.workingDirectory, true);
11201128
const result = new GitOutput();
11211129
const env = {};
@@ -1139,7 +1147,24 @@ class GitCommandManager {
11391147
ignoreReturnCode: allowAllExitCodes,
11401148
listeners: mergedListeners
11411149
};
1142-
result.exitCode = yield exec.exec(`"${this.gitPath}"`, args, options);
1150+
const execPromise = exec.exec(`"${this.gitPath}"`, args, options);
1151+
if (timeoutMs > 0) {
1152+
let timer;
1153+
const timeoutPromise = new Promise((_, reject) => {
1154+
timer = global.setTimeout(() => {
1155+
reject(new Error(`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 3).join(' ')}...`));
1156+
}, timeoutMs);
1157+
});
1158+
try {
1159+
result.exitCode = yield Promise.race([execPromise, timeoutPromise]);
1160+
}
1161+
finally {
1162+
clearTimeout(timer);
1163+
}
1164+
}
1165+
else {
1166+
result.exitCode = yield execPromise;
1167+
}
11431168
result.stdout = stdout.join('');
11441169
core.debug(result.exitCode.toString());
11451170
core.debug(result.stdout);
@@ -1448,6 +1473,10 @@ function getSource(settings) {
14481473
core.startGroup('Getting Git version info');
14491474
const git = yield getGitCommandManager(settings);
14501475
core.endGroup();
1476+
if (git) {
1477+
git.setTimeout(settings.timeout);
1478+
git.setRetryConfig(settings.retryMaxAttempts, settings.retryMinBackoff, settings.retryMaxBackoff);
1479+
}
14511480
let authHelper = null;
14521481
try {
14531482
if (git) {
@@ -2095,6 +2124,32 @@ function getInputs() {
20952124
// Determine the GitHub URL that the repository is being hosted from
20962125
result.githubServerUrl = core.getInput('github-server-url');
20972126
core.debug(`GitHub Host URL = ${result.githubServerUrl}`);
2127+
// Timeout (per-attempt, like k8s timeoutSeconds)
2128+
result.timeout = Math.floor(Number(core.getInput('timeout') || '300'));
2129+
if (isNaN(result.timeout) || result.timeout < 0) {
2130+
result.timeout = 300;
2131+
}
2132+
core.debug(`timeout = ${result.timeout}`);
2133+
// Retry max attempts (like k8s failureThreshold)
2134+
result.retryMaxAttempts = Math.floor(Number(core.getInput('retry-max-attempts') || '3'));
2135+
if (isNaN(result.retryMaxAttempts) || result.retryMaxAttempts < 1) {
2136+
result.retryMaxAttempts = 3;
2137+
}
2138+
core.debug(`retry max attempts = ${result.retryMaxAttempts}`);
2139+
// Retry backoff (like k8s periodSeconds, but as a min/max range)
2140+
result.retryMinBackoff = Math.floor(Number(core.getInput('retry-min-backoff') || '10'));
2141+
if (isNaN(result.retryMinBackoff) || result.retryMinBackoff < 0) {
2142+
result.retryMinBackoff = 10;
2143+
}
2144+
core.debug(`retry min backoff = ${result.retryMinBackoff}`);
2145+
result.retryMaxBackoff = Math.floor(Number(core.getInput('retry-max-backoff') || '20'));
2146+
if (isNaN(result.retryMaxBackoff) || result.retryMaxBackoff < 0) {
2147+
result.retryMaxBackoff = 20;
2148+
}
2149+
if (result.retryMaxBackoff < result.retryMinBackoff) {
2150+
result.retryMaxBackoff = result.retryMinBackoff;
2151+
}
2152+
core.debug(`retry max backoff = ${result.retryMaxBackoff}`);
20982153
return result;
20992154
});
21002155
}
@@ -5260,6 +5315,7 @@ class Context {
52605315
this.action = process.env.GITHUB_ACTION;
52615316
this.actor = process.env.GITHUB_ACTOR;
52625317
this.job = process.env.GITHUB_JOB;
5318+
this.runAttempt = parseInt(process.env.GITHUB_RUN_ATTEMPT, 10);
52635319
this.runNumber = parseInt(process.env.GITHUB_RUN_NUMBER, 10);
52645320
this.runId = parseInt(process.env.GITHUB_RUN_ID, 10);
52655321
this.apiUrl = (_a = process.env.GITHUB_API_URL) !== null && _a !== void 0 ? _a : `https://api.github.com`;
@@ -6136,7 +6192,7 @@ class HttpClient {
61366192
}
61376193
const usingSsl = parsedUrl.protocol === 'https:';
61386194
proxyAgent = new undici_1.ProxyAgent(Object.assign({ uri: proxyUrl.href, pipelining: !this._keepAlive ? 0 : 1 }, ((proxyUrl.username || proxyUrl.password) && {
6139-
token: `${proxyUrl.username}:${proxyUrl.password}`
6195+
token: `Basic ${Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`).toString('base64')}`
61406196
})));
61416197
this._proxyAgentDispatcher = proxyAgent;
61426198
if (usingSsl && this._ignoreSslError) {
@@ -6250,11 +6306,11 @@ function getProxyUrl(reqUrl) {
62506306
})();
62516307
if (proxyVar) {
62526308
try {
6253-
return new URL(proxyVar);
6309+
return new DecodedURL(proxyVar);
62546310
}
62556311
catch (_a) {
62566312
if (!proxyVar.startsWith('http://') && !proxyVar.startsWith('https://'))
6257-
return new URL(`http://${proxyVar}`);
6313+
return new DecodedURL(`http://${proxyVar}`);
62586314
}
62596315
}
62606316
else {
@@ -6313,6 +6369,19 @@ function isLoopbackAddress(host) {
63136369
hostLower.startsWith('[::1]') ||
63146370
hostLower.startsWith('[0:0:0:0:0:0:0:1]'));
63156371
}
6372+
class DecodedURL extends URL {
6373+
constructor(url, base) {
6374+
super(url, base);
6375+
this._decodedUsername = decodeURIComponent(super.username);
6376+
this._decodedPassword = decodeURIComponent(super.password);
6377+
}
6378+
get username() {
6379+
return this._decodedUsername;
6380+
}
6381+
get password() {
6382+
return this._decodedPassword;
6383+
}
6384+
}
63166385
//# sourceMappingURL=proxy.js.map
63176386

63186387
/***/ }),

0 commit comments

Comments
 (0)