From 5df58a66d1aead4b229b47badc6dfdccc206d0c0 Mon Sep 17 00:00:00 2001 From: Anatoly Rabkin Date: Wed, 18 Mar 2026 18:06:25 +0200 Subject: [PATCH 1/2] 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) --- README.md | 22 +++++ __test__/git-auth-helper.test.ts | 10 ++- __test__/git-directory-helper.test.ts | 4 +- action.yml | 23 +++++ dist/index.js | 91 ++++++++++++++++--- package-lock.json | 123 +++++++++++++++----------- src/git-command-manager.ts | 82 +++++++++++++---- src/git-source-provider.ts | 9 ++ src/git-source-settings.ts | 23 +++++ src/input-helper.ts | 36 ++++++++ 10 files changed, 342 insertions(+), 81 deletions(-) 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 } From 3ff67abc5ad3701cf3ff90320c2ebac45641baeb Mon Sep 17 00:00:00 2001 From: Anatoly Rabkin Date: Wed, 18 Mar 2026 19:04:59 +0200 Subject: [PATCH 2/2] Fix timeout implementation and address review feedback - Kill git process on timeout: use child_process.spawn directly for timeout-eligible operations so we have a ChildProcess handle to send SIGTERM (then SIGKILL after 5s). On Windows, SIGTERM is a forced kill so the SIGKILL fallback is effectively a no-op there. - Fix timeout:0 not working: replace falsy || coalescion with explicit empty-string check so that '0' is not replaced by the default '300'. - Refactor execGit to use an options object instead of 5 positional parameters, eliminating error-prone filler args (false, false, {}). - Pass allowAllExitCodes through to execGitWithTimeout so both code paths have consistent behavior for non-zero exit codes. - Add settled guard to prevent double-reject when both close and error events fire on the spawned process. - Handle null exit code (process killed by signal) as an error rather than silently treating it as success. - Capture stderr in error messages for the timeout path, matching the information level of the non-timeout exec path. - Log SIGKILL failures at debug level instead of empty catch block. - Warn on customListeners being ignored in the timeout path. - Emit core.warning() when invalid input values are silently replaced with defaults, so users know their configuration was rejected. - Add input validation in setTimeout (reject negative values). - Clarify retry-max-attempts semantics: total attempts including the initial attempt (3 = 1 initial + 2 retries). - Remove Kubernetes probe references from descriptions. - Use non-exhaustive list (e.g.) for network operations in docs to avoid staleness if new operations are added. - Add tests for timeout/retry input parsing (defaults, timeout:0, custom values, invalid input with warnings, backoff clamping) and command manager configuration (setTimeout, setRetryConfig, fetch). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 14 +- __test__/git-command-manager.test.ts | 81 +++++++++ __test__/input-helper.test.ts | 54 ++++++ action.yml | 11 +- dist/index.js | 220 ++++++++++++++++++----- src/git-command-manager.ts | 255 ++++++++++++++++++++++----- src/git-source-settings.ts | 9 +- src/input-helper.ts | 33 +++- 8 files changed, 556 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 8948c3b..5a0d300 100644 --- a/README.md +++ b/README.md @@ -155,20 +155,20 @@ 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. + # Timeout in seconds for each git network operation attempt (e.g. fetch, + # lfs-fetch, ls-remote). If a single attempt exceeds this, the process is + # terminated. If retries are configured (see retry-max-attempts), the operation + # will be retried. Set to 0 to disable. Default is 300 (5 minutes). # Default: 300 timeout: '' - # Maximum number of retry attempts for failed git network operations. Similar to - # Kubernetes probe failureThreshold. + # Total number of attempts for each git network operation (including the initial + # attempt). For example, 3 means one initial attempt plus up to 2 retries. # 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. + # randomly chosen between min and max. # Default: 10 retry-min-backoff: '' diff --git a/__test__/git-command-manager.test.ts b/__test__/git-command-manager.test.ts index 8a97d82..f79ba46 100644 --- a/__test__/git-command-manager.test.ts +++ b/__test__/git-command-manager.test.ts @@ -5,6 +5,17 @@ import * as commandManager from '../lib/git-command-manager' let git: commandManager.IGitCommandManager let mockExec = jest.fn() +function createMockGit(): Promise { + mockExec.mockImplementation((path, args, options) => { + if (args.includes('version')) { + options.listeners.stdout(Buffer.from('2.18')) + } + return 0 + }) + jest.spyOn(exec, 'exec').mockImplementation(mockExec) + return commandManager.createCommandManager('test', false, false) +} + describe('git-auth-helper tests', () => { beforeAll(async () => {}) @@ -494,3 +505,73 @@ describe('git user-agent with orchestration ID', () => { ) }) }) + +describe('timeout and retry configuration', () => { + beforeEach(async () => { + jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn()) + jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn()) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('setTimeout accepts valid values', async () => { + git = await createMockGit() + git.setTimeout(30) + git.setTimeout(0) + }) + + it('setTimeout rejects negative values', async () => { + git = await createMockGit() + expect(() => git.setTimeout(-1)).toThrow(/non-negative/) + }) + + it('setRetryConfig accepts valid parameters', async () => { + git = await createMockGit() + git.setRetryConfig(5, 2, 15) + }) + + it('setRetryConfig rejects min > max backoff', async () => { + git = await createMockGit() + expect(() => git.setRetryConfig(3, 20, 5)).toThrow( + /min seconds should be less than or equal to max seconds/ + ) + }) + + it('fetch without timeout uses exec', async () => { + git = await createMockGit() + // timeout defaults to 0 (disabled) + + mockExec.mockClear() + await git.fetch(['refs/heads/main'], {}) + + // exec.exec is used (via retryHelper) when no timeout + const fetchCalls = mockExec.mock.calls.filter( + (call: any[]) => (call[1] as string[]).includes('fetch') + ) + expect(fetchCalls).toHaveLength(1) + }) + + it('fetch with timeout does not use exec', async () => { + git = await createMockGit() + // Short timeout and single attempt so the test completes quickly + git.setTimeout(1) + git.setRetryConfig(1, 0, 0) + + mockExec.mockClear() + + // fetch will use spawn path (which will fail/timeout since there's + // no real git repo), but we verify exec.exec was NOT called for fetch + try { + await git.fetch(['refs/heads/main'], {}) + } catch { + // Expected: spawn will fail/timeout in test environment + } + + const fetchCalls = mockExec.mock.calls.filter( + (call: any[]) => (call[1] as string[]).includes('fetch') + ) + expect(fetchCalls).toHaveLength(0) + }, 10000) +}) diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 9514cb4..97c8851 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -144,4 +144,58 @@ describe('input-helper tests', () => { const settings: IGitSourceSettings = await inputHelper.getInputs() expect(settings.workflowOrganizationId).toBe(123456) }) + + it('sets timeout and retry defaults', async () => { + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.timeout).toBe(300) + expect(settings.retryMaxAttempts).toBe(3) + expect(settings.retryMinBackoff).toBe(10) + expect(settings.retryMaxBackoff).toBe(20) + }) + + it('allows timeout 0 to disable', async () => { + inputs.timeout = '0' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.timeout).toBe(0) + }) + + it('parses custom timeout and retry values', async () => { + inputs.timeout = '30' + inputs['retry-max-attempts'] = '5' + inputs['retry-min-backoff'] = '2' + inputs['retry-max-backoff'] = '15' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.timeout).toBe(30) + expect(settings.retryMaxAttempts).toBe(5) + expect(settings.retryMinBackoff).toBe(2) + expect(settings.retryMaxBackoff).toBe(15) + }) + + it('clamps retry-max-backoff to min when less than min and warns', async () => { + inputs['retry-min-backoff'] = '20' + inputs['retry-max-backoff'] = '5' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.retryMaxBackoff).toBe(20) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("'retry-max-backoff' (5) is less than 'retry-min-backoff' (20)") + ) + }) + + it('defaults invalid timeout to 300 and warns', async () => { + inputs.timeout = 'garbage' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.timeout).toBe(300) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Invalid value 'garbage' for 'timeout'") + ) + }) + + it('defaults negative retry-max-attempts to 3 and warns', async () => { + inputs['retry-max-attempts'] = '-1' + const settings: IGitSourceSettings = await inputHelper.getInputs() + expect(settings.retryMaxAttempts).toBe(3) + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Invalid value '-1' for 'retry-max-attempts'") + ) + }) }) diff --git a/action.yml b/action.yml index ca7c406..03789b6 100644 --- a/action.yml +++ b/action.yml @@ -97,21 +97,20 @@ inputs: 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. + Timeout in seconds for each git network operation attempt (e.g. fetch, lfs-fetch, ls-remote). + If a single attempt exceeds this, the process is terminated. + If retries are configured (see retry-max-attempts), the operation will be 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. + Total number of attempts for each git network operation (including the initial attempt). + For example, 3 means one initial attempt plus up to 2 retries. 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: > diff --git a/dist/index.js b/dist/index.js index c968f6f..00a67e1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -655,6 +655,7 @@ const io = __importStar(__nccwpck_require__(7436)); const path = __importStar(__nccwpck_require__(1017)); const regexpHelper = __importStar(__nccwpck_require__(3120)); const retryHelper = __importStar(__nccwpck_require__(2155)); +const child_process_1 = __nccwpck_require__(2081); const git_version_1 = __nccwpck_require__(3142); // Auth header not supported before 2.9 // Wire protocol v2 not supported before 2.18 @@ -737,7 +738,7 @@ class GitCommandManager { } }; // Suppress the output in order to avoid flooding annotations with innocuous errors. - yield this.execGit(args, false, true, listeners); + yield this.execGit(args, { silent: true, customListeners: listeners }); core.debug(`stderr callback is: ${stderr}`); core.debug(`errline callback is: ${errline}`); core.debug(`stdout callback is: ${stdout}`); @@ -825,7 +826,7 @@ class GitCommandManager { '--name-only', '--get-regexp', pattern - ], true); + ], { allowAllExitCodes: true }); return output.exitCode === 0; }); } @@ -854,7 +855,7 @@ class GitCommandManager { } const that = this; yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { - yield that.execGit(args, false, false, {}, that.timeoutMs); + yield that.execGit(args, { timeoutMs: that.timeoutMs }); })); }); } @@ -869,7 +870,7 @@ class GitCommandManager { '--symref', repositoryUrl, 'HEAD' - ], false, false, {}, this.timeoutMs); + ], { timeoutMs: this.timeoutMs }); })); if (output) { // Satisfy compiler, will always be set @@ -906,7 +907,7 @@ class GitCommandManager { isDetached() { return __awaiter(this, void 0, void 0, function* () { // Note, "branch --show-current" would be simpler but isn't available until Git 2.22 - const output = yield this.execGit(['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], true); + const output = yield this.execGit(['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], { allowAllExitCodes: true }); return !output.stdout.trim().startsWith('refs/heads/'); }); } @@ -915,7 +916,7 @@ class GitCommandManager { const args = ['lfs', 'fetch', 'origin', ref]; const that = this; yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { - yield that.execGit(args, false, false, {}, that.timeoutMs); + yield that.execGit(args, { timeoutMs: that.timeoutMs }); })); }); } @@ -927,8 +928,8 @@ class GitCommandManager { log1(format) { return __awaiter(this, void 0, void 0, function* () { const args = format ? ['log', '-1', format] : ['log', '-1']; - const silent = format ? false : true; - const output = yield this.execGit(args, false, silent); + const silent = !format; + const output = yield this.execGit(args, { silent }); return output.stdout; }); } @@ -958,7 +959,7 @@ class GitCommandManager { shaExists(sha) { return __awaiter(this, void 0, void 0, function* () { const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]; - const output = yield this.execGit(args, true); + const output = yield this.execGit(args, { allowAllExitCodes: true }); return output.exitCode === 0; }); } @@ -979,7 +980,10 @@ class GitCommandManager { if (recursive) { args.push('--recursive'); } - yield this.execGit(args); + const that = this; + yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { + yield that.execGit(args, { timeoutMs: that.timeoutMs }); + })); }); } submoduleUpdate(fetchDepth, recursive) { @@ -992,12 +996,15 @@ class GitCommandManager { if (recursive) { args.push('--recursive'); } - yield this.execGit(args); + const that = this; + yield this.networkRetryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { + yield that.execGit(args, { timeoutMs: that.timeoutMs }); + })); }); } submoduleStatus() { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['submodule', 'status'], true); + const output = yield this.execGit(['submodule', 'status'], { allowAllExitCodes: true }); core.debug(output.stdout); return output.exitCode === 0; }); @@ -1010,7 +1017,7 @@ class GitCommandManager { } tryClean() { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['clean', '-ffdx'], true); + const output = yield this.execGit(['clean', '-ffdx'], { allowAllExitCodes: true }); return output.exitCode === 0; }); } @@ -1021,7 +1028,7 @@ class GitCommandManager { globalConfig ? '--global' : '--local', '--unset-all', configKey - ], true); + ], { allowAllExitCodes: true }); return output.exitCode === 0; }); } @@ -1035,19 +1042,19 @@ class GitCommandManager { args.push(globalConfig ? '--global' : '--local'); } args.push('--unset', configKey, configValue); - const output = yield this.execGit(args, true); + const output = yield this.execGit(args, { allowAllExitCodes: true }); return output.exitCode === 0; }); } tryDisableAutomaticGarbageCollection() { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true); + const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], { allowAllExitCodes: true }); return output.exitCode === 0; }); } tryGetFetchUrl() { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['config', '--local', '--get', 'remote.origin.url'], true); + const output = yield this.execGit(['config', '--local', '--get', 'remote.origin.url'], { allowAllExitCodes: true }); if (output.exitCode !== 0) { return ''; } @@ -1068,7 +1075,7 @@ class GitCommandManager { args.push(globalConfig ? '--global' : '--local'); } args.push('--get-all', configKey); - const output = yield this.execGit(args, true); + const output = yield this.execGit(args, { allowAllExitCodes: true }); if (output.exitCode !== 0) { return []; } @@ -1088,7 +1095,7 @@ class GitCommandManager { args.push(globalConfig ? '--global' : '--local'); } args.push('--name-only', '--get-regexp', pattern); - const output = yield this.execGit(args, true); + const output = yield this.execGit(args, { allowAllExitCodes: true }); if (output.exitCode !== 0) { return []; } @@ -1100,7 +1107,7 @@ class GitCommandManager { } tryReset() { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['reset', '--hard', 'HEAD'], true); + const output = yield this.execGit(['reset', '--hard', 'HEAD'], { allowAllExitCodes: true }); return output.exitCode === 0; }); } @@ -1109,9 +1116,22 @@ class GitCommandManager { return this.gitVersion; }); } + /** + * Sets the timeout for network git operations. + * @param timeoutSeconds Timeout in seconds. 0 disables the timeout. + */ setTimeout(timeoutSeconds) { + if (timeoutSeconds < 0) { + throw new Error(`Timeout must be non-negative, got ${timeoutSeconds}`); + } this.timeoutMs = timeoutSeconds * 1000; } + /** + * Configures retry behavior for network git operations. + * @param maxAttempts Total attempts including the initial one. Must be >= 1. + * @param minBackoffSeconds Minimum backoff between retries. Must be <= maxBackoffSeconds. + * @param maxBackoffSeconds Maximum backoff between retries. + */ setRetryConfig(maxAttempts, minBackoffSeconds, maxBackoffSeconds) { this.networkRetryHelper = new retryHelper.RetryHelper(maxAttempts, minBackoffSeconds, maxBackoffSeconds); } @@ -1123,8 +1143,19 @@ class GitCommandManager { }); } execGit(args_1) { - return __awaiter(this, arguments, void 0, function* (args, allowAllExitCodes = false, silent = false, customListeners = {}, timeoutMs = 0) { + return __awaiter(this, arguments, void 0, function* (args, options = {}) { + const { allowAllExitCodes = false, silent = false, customListeners = {}, timeoutMs = 0 } = options; fshelper.directoryExistsSync(this.workingDirectory, true); + // Use child_process.spawn directly when timeout is set, + // so we can kill the process on timeout and avoid orphaned git processes. + // Note: customListeners are not supported in the timeout path. + if (timeoutMs > 0) { + if (customListeners && + Object.keys(customListeners).length > 0) { + core.debug('customListeners are not supported with timeoutMs and will be ignored'); + } + return yield this.execGitWithTimeout(args, timeoutMs, silent, allowAllExitCodes); + } const result = new GitOutput(); const env = {}; for (const key of Object.keys(process.env)) { @@ -1140,37 +1171,123 @@ class GitCommandManager { }; const mergedListeners = Object.assign(Object.assign({}, defaultListener), customListeners); const stdout = []; - const options = { + const execOptions = { cwd: this.workingDirectory, env, silent, ignoreReturnCode: allowAllExitCodes, listeners: mergedListeners }; - 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.exitCode = yield exec.exec(`"${this.gitPath}"`, args, execOptions); result.stdout = stdout.join(''); core.debug(result.exitCode.toString()); core.debug(result.stdout); return result; }); } + /** + * Executes a git command with a timeout. Uses child_process.spawn directly + * (instead of @actions/exec) so we can kill the process on timeout and + * terminate it cleanly. Does not support customListeners. + */ + execGitWithTimeout(args, timeoutMs, silent, allowAllExitCodes) { + return __awaiter(this, void 0, void 0, function* () { + const result = new GitOutput(); + const env = {}; + for (const key of Object.keys(process.env)) { + env[key] = process.env[key]; + } + for (const key of Object.keys(this.gitEnv)) { + env[key] = this.gitEnv[key]; + } + const stdout = []; + const stderr = []; + return new Promise((resolve, reject) => { + var _a; + const child = (0, child_process_1.spawn)(this.gitPath, args, { + cwd: this.workingDirectory, + env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => { + stdout.push(data.toString()); + }); + if (child.stderr) { + child.stderr.on('data', (data) => { + stderr.push(data.toString()); + if (!silent) { + process.stderr.write(data); + } + }); + } + let settled = false; + let timedOut = false; + let forceKillTimer; + const cleanup = () => { + clearTimeout(timer); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + }; + const timer = global.setTimeout(() => { + timedOut = true; + // SIGTERM first, then force SIGKILL after 5 seconds. + // On Windows, SIGTERM is equivalent to a forced kill, so + // the SIGKILL fallback is effectively a no-op there. + child.kill('SIGTERM'); + forceKillTimer = global.setTimeout(() => { + try { + child.kill('SIGKILL'); + } + catch (killErr) { + core.debug(`Failed to SIGKILL git process: ${killErr}`); + } + }, 5000); + if (forceKillTimer.unref) { + forceKillTimer.unref(); + } + }, timeoutMs); + if (timer.unref) { + timer.unref(); + } + child.on('close', (code) => { + if (settled) + return; + settled = true; + cleanup(); + if (timedOut) { + reject(new Error(`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 5).join(' ')}...`)); + return; + } + // null code means killed by signal (e.g. OOM killer, external SIGTERM) + if (code === null) { + const stderrText = stderr.join('').trim(); + reject(new Error(`The process 'git' was killed by a signal` + + (stderrText ? `\n${stderrText}` : ''))); + return; + } + if (code !== 0 && !allowAllExitCodes) { + const stderrText = stderr.join('').trim(); + reject(new Error(`The process 'git' failed with exit code ${code}` + + (stderrText ? `\n${stderrText}` : ''))); + return; + } + result.exitCode = code; + result.stdout = stdout.join(''); + core.debug(result.exitCode.toString()); + core.debug(result.stdout); + resolve(result); + }); + child.on('error', (err) => { + if (settled) + return; + settled = true; + cleanup(); + reject(err); + }); + }); + }); + } initializeCommandManager(workingDirectory, lfs, doSparseCheckout) { return __awaiter(this, void 0, void 0, function* () { this.workingDirectory = workingDirectory; @@ -2124,29 +2241,38 @@ 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')); + // Timeout per network operation attempt + const timeoutInput = core.getInput('timeout'); + result.timeout = Math.floor(Number(timeoutInput !== '' ? timeoutInput : '300')); if (isNaN(result.timeout) || result.timeout < 0) { + core.warning(`Invalid value '${timeoutInput}' for 'timeout' input. Using default: 300 seconds.`); 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')); + // Retry max attempts (total attempts including initial) + const retryMaxAttemptsInput = core.getInput('retry-max-attempts'); + result.retryMaxAttempts = Math.floor(Number(retryMaxAttemptsInput !== '' ? retryMaxAttemptsInput : '3')); if (isNaN(result.retryMaxAttempts) || result.retryMaxAttempts < 1) { + core.warning(`Invalid value '${retryMaxAttemptsInput}' for 'retry-max-attempts' input. Using default: 3.`); 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')); + // Retry backoff range + const retryMinBackoffInput = core.getInput('retry-min-backoff'); + result.retryMinBackoff = Math.floor(Number(retryMinBackoffInput !== '' ? retryMinBackoffInput : '10')); if (isNaN(result.retryMinBackoff) || result.retryMinBackoff < 0) { + core.warning(`Invalid value '${retryMinBackoffInput}' for 'retry-min-backoff' input. Using default: 10 seconds.`); result.retryMinBackoff = 10; } core.debug(`retry min backoff = ${result.retryMinBackoff}`); - result.retryMaxBackoff = Math.floor(Number(core.getInput('retry-max-backoff') || '20')); + const retryMaxBackoffInput = core.getInput('retry-max-backoff'); + result.retryMaxBackoff = Math.floor(Number(retryMaxBackoffInput !== '' ? retryMaxBackoffInput : '20')); if (isNaN(result.retryMaxBackoff) || result.retryMaxBackoff < 0) { + core.warning(`Invalid value '${retryMaxBackoffInput}' for 'retry-max-backoff' input. Using default: 20 seconds.`); result.retryMaxBackoff = 20; } if (result.retryMaxBackoff < result.retryMinBackoff) { + core.warning(`'retry-max-backoff' (${result.retryMaxBackoff}) is less than 'retry-min-backoff' (${result.retryMinBackoff}). Using retry-min-backoff value for both.`); result.retryMaxBackoff = result.retryMinBackoff; } core.debug(`retry max backoff = ${result.retryMaxBackoff}`); diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 580bffc..40bb4e7 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -7,6 +7,7 @@ import * as path from 'path' import * as refHelper from './ref-helper' import * as regexpHelper from './regexp-helper' import * as retryHelper from './retry-helper' +import {spawn} from 'child_process' import {GitVersion} from './git-version' // Auth header not supported before 2.9 @@ -176,7 +177,7 @@ class GitCommandManager { } // Suppress the output in order to avoid flooding annotations with innocuous errors. - await this.execGit(args, false, true, listeners) + await this.execGit(args, {silent: true, customListeners: listeners}) core.debug(`stderr callback is: ${stderr}`) core.debug(`errline callback is: ${errline}`) @@ -277,7 +278,7 @@ class GitCommandManager { '--get-regexp', pattern ], - true + {allowAllExitCodes: true} ) return output.exitCode === 0 } @@ -321,7 +322,7 @@ class GitCommandManager { const that = this await this.networkRetryHelper.execute(async () => { - await that.execGit(args, false, false, {}, that.timeoutMs) + await that.execGit(args, {timeoutMs: that.timeoutMs}) }) } @@ -337,10 +338,7 @@ class GitCommandManager { repositoryUrl, 'HEAD' ], - false, - false, - {}, - this.timeoutMs + {timeoutMs: this.timeoutMs} ) }) @@ -386,7 +384,7 @@ class GitCommandManager { // Note, "branch --show-current" would be simpler but isn't available until Git 2.22 const output = await this.execGit( ['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], - true + {allowAllExitCodes: true} ) return !output.stdout.trim().startsWith('refs/heads/') } @@ -396,7 +394,7 @@ class GitCommandManager { const that = this await this.networkRetryHelper.execute(async () => { - await that.execGit(args, false, false, {}, that.timeoutMs) + await that.execGit(args, {timeoutMs: that.timeoutMs}) }) } @@ -406,8 +404,8 @@ class GitCommandManager { async log1(format?: string): Promise { const args = format ? ['log', '-1', format] : ['log', '-1'] - const silent = format ? false : true - const output = await this.execGit(args, false, silent) + const silent = !format + const output = await this.execGit(args, {silent}) return output.stdout } @@ -436,7 +434,7 @@ class GitCommandManager { async shaExists(sha: string): Promise { const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`] - const output = await this.execGit(args, true) + const output = await this.execGit(args, {allowAllExitCodes: true}) return output.exitCode === 0 } @@ -457,7 +455,10 @@ class GitCommandManager { args.push('--recursive') } - await this.execGit(args) + const that = this + await this.networkRetryHelper.execute(async () => { + await that.execGit(args, {timeoutMs: that.timeoutMs}) + }) } async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise { @@ -471,11 +472,14 @@ class GitCommandManager { args.push('--recursive') } - await this.execGit(args) + const that = this + await this.networkRetryHelper.execute(async () => { + await that.execGit(args, {timeoutMs: that.timeoutMs}) + }) } async submoduleStatus(): Promise { - const output = await this.execGit(['submodule', 'status'], true) + const output = await this.execGit(['submodule', 'status'], {allowAllExitCodes: true}) core.debug(output.stdout) return output.exitCode === 0 } @@ -486,7 +490,7 @@ class GitCommandManager { } async tryClean(): Promise { - const output = await this.execGit(['clean', '-ffdx'], true) + const output = await this.execGit(['clean', '-ffdx'], {allowAllExitCodes: true}) return output.exitCode === 0 } @@ -501,7 +505,7 @@ class GitCommandManager { '--unset-all', configKey ], - true + {allowAllExitCodes: true} ) return output.exitCode === 0 } @@ -520,14 +524,14 @@ class GitCommandManager { } args.push('--unset', configKey, configValue) - const output = await this.execGit(args, true) + const output = await this.execGit(args, {allowAllExitCodes: true}) return output.exitCode === 0 } async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit( ['config', '--local', 'gc.auto', '0'], - true + {allowAllExitCodes: true} ) return output.exitCode === 0 } @@ -535,7 +539,7 @@ class GitCommandManager { async tryGetFetchUrl(): Promise { const output = await this.execGit( ['config', '--local', '--get', 'remote.origin.url'], - true + {allowAllExitCodes: true} ) if (output.exitCode !== 0) { @@ -563,7 +567,7 @@ class GitCommandManager { } args.push('--get-all', configKey) - const output = await this.execGit(args, true) + const output = await this.execGit(args, {allowAllExitCodes: true}) if (output.exitCode !== 0) { return [] @@ -588,7 +592,7 @@ class GitCommandManager { } args.push('--name-only', '--get-regexp', pattern) - const output = await this.execGit(args, true) + const output = await this.execGit(args, {allowAllExitCodes: true}) if (output.exitCode !== 0) { return [] @@ -601,7 +605,7 @@ class GitCommandManager { } async tryReset(): Promise { - const output = await this.execGit(['reset', '--hard', 'HEAD'], true) + const output = await this.execGit(['reset', '--hard', 'HEAD'], {allowAllExitCodes: true}) return output.exitCode === 0 } @@ -609,10 +613,23 @@ class GitCommandManager { return this.gitVersion } + /** + * Sets the timeout for network git operations. + * @param timeoutSeconds Timeout in seconds. 0 disables the timeout. + */ setTimeout(timeoutSeconds: number): void { + if (timeoutSeconds < 0) { + throw new Error(`Timeout must be non-negative, got ${timeoutSeconds}`) + } this.timeoutMs = timeoutSeconds * 1000 } + /** + * Configures retry behavior for network git operations. + * @param maxAttempts Total attempts including the initial one. Must be >= 1. + * @param minBackoffSeconds Minimum backoff between retries. Must be <= maxBackoffSeconds. + * @param maxBackoffSeconds Maximum backoff between retries. + */ setRetryConfig( maxAttempts: number, minBackoffSeconds: number, @@ -641,13 +658,42 @@ class GitCommandManager { private async execGit( args: string[], - allowAllExitCodes = false, - silent = false, - customListeners = {}, - timeoutMs = 0 + options: { + allowAllExitCodes?: boolean + silent?: boolean + customListeners?: {} + timeoutMs?: number + } = {} ): Promise { + const { + allowAllExitCodes = false, + silent = false, + customListeners = {}, + timeoutMs = 0 + } = options + fshelper.directoryExistsSync(this.workingDirectory, true) + // Use child_process.spawn directly when timeout is set, + // so we can kill the process on timeout and avoid orphaned git processes. + // Note: customListeners are not supported in the timeout path. + if (timeoutMs > 0) { + if ( + customListeners && + Object.keys(customListeners).length > 0 + ) { + core.debug( + 'customListeners are not supported with timeoutMs and will be ignored' + ) + } + return await this.execGitWithTimeout( + args, + timeoutMs, + silent, + allowAllExitCodes + ) + } + const result = new GitOutput() const env = {} @@ -667,7 +713,7 @@ class GitCommandManager { const mergedListeners = {...defaultListener, ...customListeners} const stdout: string[] = [] - const options = { + const execOptions = { cwd: this.workingDirectory, env, silent, @@ -675,27 +721,7 @@ class GitCommandManager { listeners: mergedListeners } - 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.exitCode = await exec.exec(`"${this.gitPath}"`, args, execOptions) result.stdout = stdout.join('') @@ -705,6 +731,137 @@ class GitCommandManager { return result } + /** + * Executes a git command with a timeout. Uses child_process.spawn directly + * (instead of @actions/exec) so we can kill the process on timeout and + * terminate it cleanly. Does not support customListeners. + */ + private async execGitWithTimeout( + args: string[], + timeoutMs: number, + silent: boolean, + allowAllExitCodes: boolean + ): Promise { + const result = new GitOutput() + + const env: {[key: string]: string} = {} + for (const key of Object.keys(process.env)) { + env[key] = process.env[key] as string + } + for (const key of Object.keys(this.gitEnv)) { + env[key] = this.gitEnv[key] + } + + const stdout: string[] = [] + const stderr: string[] = [] + + return new Promise((resolve, reject) => { + const child = spawn(this.gitPath, args, { + cwd: this.workingDirectory, + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + child.stdout?.on('data', (data: Buffer) => { + stdout.push(data.toString()) + }) + + if (child.stderr) { + child.stderr.on('data', (data: Buffer) => { + stderr.push(data.toString()) + if (!silent) { + process.stderr.write(data) + } + }) + } + + let settled = false + let timedOut = false + let forceKillTimer: ReturnType | undefined + + const cleanup = (): void => { + clearTimeout(timer) + if (forceKillTimer) { + clearTimeout(forceKillTimer) + } + } + + const timer = global.setTimeout(() => { + timedOut = true + // SIGTERM first, then force SIGKILL after 5 seconds. + // On Windows, SIGTERM is equivalent to a forced kill, so + // the SIGKILL fallback is effectively a no-op there. + child.kill('SIGTERM') + forceKillTimer = global.setTimeout(() => { + try { + child.kill('SIGKILL') + } catch (killErr) { + core.debug( + `Failed to SIGKILL git process: ${killErr}` + ) + } + }, 5000) + if (forceKillTimer.unref) { + forceKillTimer.unref() + } + }, timeoutMs) + if (timer.unref) { + timer.unref() + } + + child.on('close', (code: number | null) => { + if (settled) return + settled = true + cleanup() + + if (timedOut) { + reject( + new Error( + `Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 5).join(' ')}...` + ) + ) + return + } + + // null code means killed by signal (e.g. OOM killer, external SIGTERM) + if (code === null) { + const stderrText = stderr.join('').trim() + reject( + new Error( + `The process 'git' was killed by a signal` + + (stderrText ? `\n${stderrText}` : '') + ) + ) + return + } + + if (code !== 0 && !allowAllExitCodes) { + const stderrText = stderr.join('').trim() + reject( + new Error( + `The process 'git' failed with exit code ${code}` + + (stderrText ? `\n${stderrText}` : '') + ) + ) + return + } + + result.exitCode = code + result.stdout = stdout.join('') + core.debug(result.exitCode.toString()) + core.debug(result.stdout) + resolve(result) + }) + + child.on('error', (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + }) + }) + } + private async initializeCommandManager( workingDirectory: string, lfs: boolean, diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index fd44634..22ce33e 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -120,20 +120,19 @@ export interface IGitSourceSettings { 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 in seconds for each network git operation attempt (e.g. fetch, lfs-fetch, ls-remote). + * 0 means no timeout. */ timeout: number /** - * Maximum number of retry attempts for failed network git operations. - * Similar to Kubernetes probe failureThreshold. + * Total number of attempts for each network git operation (including the initial attempt). + * For example, 3 means one initial attempt plus up to 2 retries. */ retryMaxAttempts: number /** * Minimum backoff time in seconds between retry attempts. - * Similar to Kubernetes probe periodSeconds. */ retryMinBackoff: number diff --git a/src/input-helper.ts b/src/input-helper.ts index a40a169..4fc2368 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -161,38 +161,57 @@ 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')) + // Timeout per network operation attempt + const timeoutInput = core.getInput('timeout') + result.timeout = Math.floor(Number(timeoutInput !== '' ? timeoutInput : '300')) if (isNaN(result.timeout) || result.timeout < 0) { + core.warning( + `Invalid value '${timeoutInput}' for 'timeout' input. Using default: 300 seconds.` + ) result.timeout = 300 } core.debug(`timeout = ${result.timeout}`) - // Retry max attempts (like k8s failureThreshold) + // Retry max attempts (total attempts including initial) + const retryMaxAttemptsInput = core.getInput('retry-max-attempts') result.retryMaxAttempts = Math.floor( - Number(core.getInput('retry-max-attempts') || '3') + Number(retryMaxAttemptsInput !== '' ? retryMaxAttemptsInput : '3') ) if (isNaN(result.retryMaxAttempts) || result.retryMaxAttempts < 1) { + core.warning( + `Invalid value '${retryMaxAttemptsInput}' for 'retry-max-attempts' input. Using default: 3.` + ) result.retryMaxAttempts = 3 } core.debug(`retry max attempts = ${result.retryMaxAttempts}`) - // Retry backoff (like k8s periodSeconds, but as a min/max range) + // Retry backoff range + const retryMinBackoffInput = core.getInput('retry-min-backoff') result.retryMinBackoff = Math.floor( - Number(core.getInput('retry-min-backoff') || '10') + Number(retryMinBackoffInput !== '' ? retryMinBackoffInput : '10') ) if (isNaN(result.retryMinBackoff) || result.retryMinBackoff < 0) { + core.warning( + `Invalid value '${retryMinBackoffInput}' for 'retry-min-backoff' input. Using default: 10 seconds.` + ) result.retryMinBackoff = 10 } core.debug(`retry min backoff = ${result.retryMinBackoff}`) + const retryMaxBackoffInput = core.getInput('retry-max-backoff') result.retryMaxBackoff = Math.floor( - Number(core.getInput('retry-max-backoff') || '20') + Number(retryMaxBackoffInput !== '' ? retryMaxBackoffInput : '20') ) if (isNaN(result.retryMaxBackoff) || result.retryMaxBackoff < 0) { + core.warning( + `Invalid value '${retryMaxBackoffInput}' for 'retry-max-backoff' input. Using default: 20 seconds.` + ) result.retryMaxBackoff = 20 } if (result.retryMaxBackoff < result.retryMinBackoff) { + core.warning( + `'retry-max-backoff' (${result.retryMaxBackoff}) is less than 'retry-min-backoff' (${result.retryMinBackoff}). Using retry-min-backoff value for both.` + ) result.retryMaxBackoff = result.retryMinBackoff } core.debug(`retry max backoff = ${result.retryMaxBackoff}`)