mirror of
https://github.com/actions/checkout.git
synced 2026-05-07 02:17:36 +02:00
Merge 3ff67abc5a into 900f2210b1
This commit is contained in:
commit
d384b61093
12 changed files with 815 additions and 119 deletions
|
|
@ -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
|
||||
|
|
@ -80,6 +81,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 +111,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() {}
|
||||
|
|
@ -168,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}`)
|
||||
|
|
@ -269,7 +278,7 @@ class GitCommandManager {
|
|||
'--get-regexp',
|
||||
pattern
|
||||
],
|
||||
true
|
||||
{allowAllExitCodes: true}
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
|
@ -312,22 +321,25 @@ class GitCommandManager {
|
|||
}
|
||||
|
||||
const that = this
|
||||
await retryHelper.execute(async () => {
|
||||
await that.execGit(args)
|
||||
await this.networkRetryHelper.execute(async () => {
|
||||
await that.execGit(args, {timeoutMs: 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'
|
||||
],
|
||||
{timeoutMs: this.timeoutMs}
|
||||
)
|
||||
})
|
||||
|
||||
if (output) {
|
||||
|
|
@ -372,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/')
|
||||
}
|
||||
|
|
@ -381,8 +393,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, {timeoutMs: that.timeoutMs})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -392,8 +404,8 @@ class GitCommandManager {
|
|||
|
||||
async log1(format?: string): Promise<string> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +434,7 @@ class GitCommandManager {
|
|||
|
||||
async shaExists(sha: string): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -443,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<void> {
|
||||
|
|
@ -457,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<boolean> {
|
||||
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
|
||||
}
|
||||
|
|
@ -472,7 +490,7 @@ class GitCommandManager {
|
|||
}
|
||||
|
||||
async tryClean(): Promise<boolean> {
|
||||
const output = await this.execGit(['clean', '-ffdx'], true)
|
||||
const output = await this.execGit(['clean', '-ffdx'], {allowAllExitCodes: true})
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
|
|
@ -487,7 +505,7 @@ class GitCommandManager {
|
|||
'--unset-all',
|
||||
configKey
|
||||
],
|
||||
true
|
||||
{allowAllExitCodes: true}
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
|
@ -506,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<boolean> {
|
||||
const output = await this.execGit(
|
||||
['config', '--local', 'gc.auto', '0'],
|
||||
true
|
||||
{allowAllExitCodes: true}
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
|
@ -521,7 +539,7 @@ class GitCommandManager {
|
|||
async tryGetFetchUrl(): Promise<string> {
|
||||
const output = await this.execGit(
|
||||
['config', '--local', '--get', 'remote.origin.url'],
|
||||
true
|
||||
{allowAllExitCodes: true}
|
||||
)
|
||||
|
||||
if (output.exitCode !== 0) {
|
||||
|
|
@ -549,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 []
|
||||
|
|
@ -574,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 []
|
||||
|
|
@ -587,7 +605,7 @@ class GitCommandManager {
|
|||
}
|
||||
|
||||
async tryReset(): Promise<boolean> {
|
||||
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
|
||||
const output = await this.execGit(['reset', '--hard', 'HEAD'], {allowAllExitCodes: true})
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
|
|
@ -595,6 +613,35 @@ 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,
|
||||
maxBackoffSeconds: number
|
||||
): void {
|
||||
this.networkRetryHelper = new retryHelper.RetryHelper(
|
||||
maxAttempts,
|
||||
minBackoffSeconds,
|
||||
maxBackoffSeconds
|
||||
)
|
||||
}
|
||||
|
||||
static async createCommandManager(
|
||||
workingDirectory: string,
|
||||
lfs: boolean,
|
||||
|
|
@ -611,12 +658,42 @@ class GitCommandManager {
|
|||
|
||||
private async execGit(
|
||||
args: string[],
|
||||
allowAllExitCodes = false,
|
||||
silent = false,
|
||||
customListeners = {}
|
||||
options: {
|
||||
allowAllExitCodes?: boolean
|
||||
silent?: boolean
|
||||
customListeners?: {}
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
): Promise<GitOutput> {
|
||||
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 = {}
|
||||
|
|
@ -636,7 +713,7 @@ class GitCommandManager {
|
|||
const mergedListeners = {...defaultListener, ...customListeners}
|
||||
|
||||
const stdout: string[] = []
|
||||
const options = {
|
||||
const execOptions = {
|
||||
cwd: this.workingDirectory,
|
||||
env,
|
||||
silent,
|
||||
|
|
@ -644,7 +721,8 @@ class GitCommandManager {
|
|||
listeners: mergedListeners
|
||||
}
|
||||
|
||||
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
|
||||
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, execOptions)
|
||||
|
||||
result.stdout = stdout.join('')
|
||||
|
||||
core.debug(result.exitCode.toString())
|
||||
|
|
@ -653,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<GitOutput> {
|
||||
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<GitOutput>((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<typeof setTimeout> | 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,
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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 (e.g. fetch, lfs-fetch, ls-remote).
|
||||
* 0 means no timeout.
|
||||
*/
|
||||
timeout: number
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
retryMinBackoff: number
|
||||
|
||||
/**
|
||||
* Maximum backoff time in seconds between retry attempts.
|
||||
*/
|
||||
retryMaxBackoff: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,5 +161,60 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||
result.githubServerUrl = core.getInput('github-server-url')
|
||||
core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
|
||||
|
||||
// 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 (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 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}`)
|
||||
|
||||
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}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue