diff --git a/README.md b/README.md index f0f65f9..8948c3b 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,28 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # Default: true 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 # environment defaults to fetch from the same instance that the workflow is # running from unless specified. Example URLs are https://github.com or diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index ad3566a..76e5696 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -1146,7 +1146,9 @@ async function setup(testName: string): Promise { } ), tryReset: jest.fn(), - version: jest.fn() + version: jest.fn(), + setTimeout: jest.fn(), + setRetryConfig: jest.fn() } settings = { @@ -1173,7 +1175,11 @@ async function setup(testName: string): Promise { sshUser: '', workflowOrganizationId: 123456, setSafeDirectory: true, - githubServerUrl: githubServerUrl + githubServerUrl: githubServerUrl, + timeout: 300, + retryMaxAttempts: 3, + retryMinBackoff: 10, + retryMaxBackoff: 20 } } diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index de79dc8..7e62c47 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -506,6 +506,8 @@ async function setup(testName: string): Promise { tryReset: jest.fn(async () => { return true }), - version: jest.fn() + version: jest.fn(), + setTimeout: jest.fn(), + setRetryConfig: jest.fn() } } diff --git a/action.yml b/action.yml index 767c416..ca7c406 100644 --- a/action.yml +++ b/action.yml @@ -95,6 +95,29 @@ inputs: set-safe-directory: description: Add repository path as safe.directory for Git global config by running `git config --global --add safe.directory ` 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: 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 diff --git a/dist/index.js b/dist/index.js index fe3f317..c968f6f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 /***/ }), diff --git a/package-lock.json b/package-lock.json index 98eb420..824543a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,20 +69,25 @@ } }, "node_modules/@actions/github": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.0.tgz", - "integrity": "sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz", + "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", + "license": "MIT", "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", - "@octokit/plugin-paginate-rest": "^9.0.0", - "@octokit/plugin-rest-endpoint-methods": "^10.0.0" + "@octokit/plugin-paginate-rest": "^9.2.2", + "@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": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.1.tgz", - "integrity": "sha512-KhC/cZsq7f8I4LfZSJKgCvEwfkE8o1538VoBeoGzokVLLnbFDEAdFD3UhoMklxo2un9NJVBdANOresx7vTHlHw==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "license": "MIT", "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" @@ -681,10 +686,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -741,10 +747,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -810,10 +817,11 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1784,10 +1792,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3136,10 +3145,11 @@ } }, "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3214,10 +3224,11 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3313,10 +3324,11 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3536,10 +3548,11 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -3590,10 +3603,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.3", @@ -3779,10 +3793,11 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4579,10 +4594,11 @@ } }, "node_modules/jake/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5186,10 +5202,11 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5486,12 +5503,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6564,10 +6582,11 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index f5ba40e..580bffc 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -80,6 +80,12 @@ export interface IGitCommandManager { ): Promise tryReset(): Promise version(): Promise + setTimeout(timeoutSeconds: number): void + setRetryConfig( + maxAttempts: number, + minBackoffSeconds: number, + maxBackoffSeconds: number + ): void } export async function createCommandManager( @@ -104,6 +110,8 @@ class GitCommandManager { private doSparseCheckout = false private workingDirectory = '' private gitVersion: GitVersion = new GitVersion() + private timeoutMs = 0 + private networkRetryHelper = new retryHelper.RetryHelper() // Private constructor; use createCommandManager() private constructor() {} @@ -312,22 +320,28 @@ class GitCommandManager { } const that = this - await retryHelper.execute(async () => { - await that.execGit(args) + await this.networkRetryHelper.execute(async () => { + await that.execGit(args, false, false, {}, that.timeoutMs) }) } async getDefaultBranch(repositoryUrl: string): Promise { let output: GitOutput | undefined - await retryHelper.execute(async () => { - output = await this.execGit([ - 'ls-remote', - '--quiet', - '--exit-code', - '--symref', - repositoryUrl, - 'HEAD' - ]) + await this.networkRetryHelper.execute(async () => { + output = await this.execGit( + [ + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + repositoryUrl, + 'HEAD' + ], + false, + false, + {}, + this.timeoutMs + ) }) if (output) { @@ -381,8 +395,8 @@ class GitCommandManager { const args = ['lfs', 'fetch', 'origin', ref] const that = this - await retryHelper.execute(async () => { - await that.execGit(args) + await this.networkRetryHelper.execute(async () => { + await that.execGit(args, false, false, {}, that.timeoutMs) }) } @@ -595,6 +609,22 @@ class GitCommandManager { 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( workingDirectory: string, lfs: boolean, @@ -613,7 +643,8 @@ class GitCommandManager { args: string[], allowAllExitCodes = false, silent = false, - customListeners = {} + customListeners = {}, + timeoutMs = 0 ): Promise { fshelper.directoryExistsSync(this.workingDirectory, true) @@ -644,7 +675,28 @@ class GitCommandManager { 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 + 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 = await Promise.race([execPromise, timeoutPromise]) + } finally { + clearTimeout(timer!) + } + } else { + result.exitCode = await execPromise + } + result.stdout = stdout.join('') core.debug(result.exitCode.toString()) diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index ec87178..a97cd94 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -39,6 +39,15 @@ export async function getSource(settings: IGitSourceSettings): Promise { const git = await getGitCommandManager(settings) core.endGroup() + if (git) { + git.setTimeout(settings.timeout) + git.setRetryConfig( + settings.retryMaxAttempts, + settings.retryMinBackoff, + settings.retryMaxBackoff + ) + } + let authHelper: gitAuthHelper.IGitAuthHelper | null = null try { if (git) { diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index 4e41ac3..fd44634 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -118,4 +118,27 @@ export interface IGitSourceSettings { * User override on the GitHub Server/Host URL that hosts the repository to be cloned */ 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 } diff --git a/src/input-helper.ts b/src/input-helper.ts index 059232f..a40a169 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -161,5 +161,41 @@ export async function getInputs(): Promise { 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 }