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:
parent
0c366fd6a8
commit
5df58a66d1
10 changed files with 342 additions and 81 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue