mirror of
https://github.com/actions/checkout.git
synced 2026-03-19 02:49:07 +00:00
- 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) <noreply@anthropic.com>
199 lines
6.8 KiB
TypeScript
199 lines
6.8 KiB
TypeScript
import * as core from '@actions/core'
|
|
import * as fsHelper from '../lib/fs-helper'
|
|
import * as github from '@actions/github'
|
|
import * as inputHelper from '../lib/input-helper'
|
|
import * as path from 'path'
|
|
import * as workflowContextHelper from '../lib/workflow-context-helper'
|
|
import {IGitSourceSettings} from '../lib/git-source-settings'
|
|
|
|
const originalGitHubWorkspace = process.env['GITHUB_WORKSPACE']
|
|
const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
|
|
|
|
// Inputs for mock @actions/core
|
|
let inputs = {} as any
|
|
|
|
// Shallow clone original @actions/github context
|
|
let originalContext = {...github.context}
|
|
|
|
describe('input-helper tests', () => {
|
|
beforeAll(() => {
|
|
// Mock getInput
|
|
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
|
return inputs[name]
|
|
})
|
|
|
|
// Mock error/warning/info/debug
|
|
jest.spyOn(core, 'error').mockImplementation(jest.fn())
|
|
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
|
|
jest.spyOn(core, 'info').mockImplementation(jest.fn())
|
|
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
|
|
|
|
// Mock github context
|
|
jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => {
|
|
return {
|
|
owner: 'some-owner',
|
|
repo: 'some-repo'
|
|
}
|
|
})
|
|
github.context.ref = 'refs/heads/some-ref'
|
|
github.context.sha = '1234567890123456789012345678901234567890'
|
|
|
|
// Mock ./fs-helper directoryExistsSync()
|
|
jest
|
|
.spyOn(fsHelper, 'directoryExistsSync')
|
|
.mockImplementation((path: string) => path == gitHubWorkspace)
|
|
|
|
// Mock ./workflowContextHelper getOrganizationId()
|
|
jest
|
|
.spyOn(workflowContextHelper, 'getOrganizationId')
|
|
.mockImplementation(() => Promise.resolve(123456))
|
|
|
|
// GitHub workspace
|
|
process.env['GITHUB_WORKSPACE'] = gitHubWorkspace
|
|
})
|
|
|
|
beforeEach(() => {
|
|
// Reset inputs
|
|
inputs = {}
|
|
})
|
|
|
|
afterAll(() => {
|
|
// Restore GitHub workspace
|
|
delete process.env['GITHUB_WORKSPACE']
|
|
if (originalGitHubWorkspace) {
|
|
process.env['GITHUB_WORKSPACE'] = originalGitHubWorkspace
|
|
}
|
|
|
|
// Restore @actions/github context
|
|
github.context.ref = originalContext.ref
|
|
github.context.sha = originalContext.sha
|
|
|
|
// Restore
|
|
jest.restoreAllMocks()
|
|
})
|
|
|
|
it('sets defaults', async () => {
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings).toBeTruthy()
|
|
expect(settings.authToken).toBeFalsy()
|
|
expect(settings.clean).toBe(true)
|
|
expect(settings.commit).toBeTruthy()
|
|
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
|
|
expect(settings.filter).toBe(undefined)
|
|
expect(settings.sparseCheckout).toBe(undefined)
|
|
expect(settings.sparseCheckoutConeMode).toBe(true)
|
|
expect(settings.fetchDepth).toBe(1)
|
|
expect(settings.fetchTags).toBe(false)
|
|
expect(settings.showProgress).toBe(true)
|
|
expect(settings.lfs).toBe(false)
|
|
expect(settings.ref).toBe('refs/heads/some-ref')
|
|
expect(settings.repositoryName).toBe('some-repo')
|
|
expect(settings.repositoryOwner).toBe('some-owner')
|
|
expect(settings.repositoryPath).toBe(gitHubWorkspace)
|
|
expect(settings.setSafeDirectory).toBe(true)
|
|
})
|
|
|
|
it('qualifies ref', async () => {
|
|
let originalRef = github.context.ref
|
|
try {
|
|
github.context.ref = 'some-unqualified-ref'
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings).toBeTruthy()
|
|
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
|
|
expect(settings.ref).toBe('refs/heads/some-unqualified-ref')
|
|
} finally {
|
|
github.context.ref = originalRef
|
|
}
|
|
})
|
|
|
|
it('requires qualified repo', async () => {
|
|
inputs.repository = 'some-unqualified-repo'
|
|
try {
|
|
await inputHelper.getInputs()
|
|
throw 'should not reach here'
|
|
} catch (err) {
|
|
expect(`(${(err as any).message}`).toMatch(
|
|
"Invalid repository 'some-unqualified-repo'"
|
|
)
|
|
}
|
|
})
|
|
|
|
it('roots path', async () => {
|
|
inputs.path = 'some-directory/some-subdirectory'
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings.repositoryPath).toBe(
|
|
path.join(gitHubWorkspace, 'some-directory', 'some-subdirectory')
|
|
)
|
|
})
|
|
|
|
it('sets ref to empty when explicit sha', async () => {
|
|
inputs.ref = '1111111111222222222233333333334444444444'
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings.ref).toBeFalsy()
|
|
expect(settings.commit).toBe('1111111111222222222233333333334444444444')
|
|
})
|
|
|
|
it('sets sha to empty when explicit ref', async () => {
|
|
inputs.ref = 'refs/heads/some-other-ref'
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings.ref).toBe('refs/heads/some-other-ref')
|
|
expect(settings.commit).toBeFalsy()
|
|
})
|
|
|
|
it('sets workflow organization ID', async () => {
|
|
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', async () => {
|
|
inputs['retry-min-backoff'] = '20'
|
|
inputs['retry-max-backoff'] = '5'
|
|
const settings: IGitSourceSettings = await inputHelper.getInputs()
|
|
expect(settings.retryMaxBackoff).toBe(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'")
|
|
)
|
|
})
|
|
})
|