3
0
Fork 0
mirror of https://code.forgejo.org/actions/checkout.git synced 2026-03-20 15:15:51 +00:00

Add configurable timeout and retry for git network operations

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

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

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

View file

@ -80,6 +80,12 @@ export interface IGitCommandManager {
): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
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<string> {
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<GitOutput> {
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<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timer = global.setTimeout(() => {
reject(
new Error(
`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 3).join(' ')}...`
)
)
}, timeoutMs)
})
try {
result.exitCode = await Promise.race([execPromise, timeoutPromise])
} finally {
clearTimeout(timer!)
}
} else {
result.exitCode = await execPromise
}
result.stdout = stdout.join('')
core.debug(result.exitCode.toString())

View file

@ -39,6 +39,15 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
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) {

View file

@ -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
}

View file

@ -161,5 +161,41 @@ export async function getInputs(): Promise<IGitSourceSettings> {
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
}