Compare commits

..

5 Commits

Author SHA1 Message Date
Peter Evans d9ef76f1ac fix(security): prevent path traversal in credentials file deletion
Use path.resolve() to normalize paths before comparison in
removeIncludeIfCredentials(). The previous startsWith() check was
vulnerable to path traversal attacks where a path like
"/tmp/runner/../../../etc/passwd" would pass the check but resolve
outside RUNNER_TEMP.

Also append path.sep to prevent false positives (e.g., /tmp/runner2
matching /tmp/runner).
2026-01-23 10:06:08 +00:00
Peter Evans ca2f66fc96 fix: only remove credentials created by this action instance
Previously, removeIncludeIfCredentials() deleted all includeIf.gitdir
entries matching git-credentials-*.config, regardless of which action
created them. This broke subsequent workflow steps that relied on
credentials persisted by other actions (e.g., actions/checkout@v6).

Now the cleanup only removes the specific credentials file and config
entries created by this action instance, leaving other actions'
credentials intact.
2026-01-23 09:43:33 +00:00
Peter Evans 64240115db fix: use --fixed-value flag when unsetting git config values
The tryConfigUnsetValue method was passing file paths directly to
`git config --unset`, which treats the value argument as an extended
regular expression. File paths contain `.` characters that would match
any character instead of literal periods, potentially causing incorrect
matches.

Adding the --fixed-value flag ensures the value is treated as a literal
string, fixing credential config cleanup in the v6-style authentication.
2026-01-21 17:59:11 +00:00
Peter Evans 2df30281e1 fix: add type annotation to fix TS2345 error in integration test
The credentialIncludes array was missing a type annotation, causing
TypeScript to infer it as never[] and reject string pushes.
2026-01-21 17:33:33 +00:00
Peter Evans 4924300074 feat: adopt checkout@v6 auth pattern using credentials files
Replace the workaround that hid/restored checkout@v6 credential files
with a proper implementation that aligns with actions/checkout@v6's
new authentication approach.

Changes:
- Store credentials in separate config file in RUNNER_TEMP with UUID
- Use git's includeIf.gitdir mechanism to conditionally include credentials
- Support both host and Docker container paths for credential resolution
- Add worktree path support for git worktrees
- Maintain backwards compatibility with checkout@v4/v5 old-style auth

GitCommandManager:
- Add configFile parameter to config() method
- Add tryConfigUnsetValue() for key-value specific unset
- Add tryGetConfigValues() for multi-value config keys
- Add tryGetConfigKeys() for regex pattern matching config keys

GitConfigHelper:
- Remove hacky hide/unhide credential file approach
- Add getCredentialsConfigPath() for UUID-based credential file paths
- Add configureIncludeIf() for setting up includeIf entries
- Add removeIncludeIfCredentials() for cleanup
- Retain setExtraheaderConfig() for restoring old-style persisted auth
2026-01-21 16:58:41 +00:00
12 changed files with 1765 additions and 1269 deletions
+5 -5
View File
@@ -29,11 +29,11 @@ jobs:
- run: npm run format-check
- run: npm run lint
- run: npm run test
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v6
with:
name: dist
path: dist
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v6
with:
name: action.yml
path: action.yml
@@ -50,12 +50,12 @@ jobs:
with:
ref: main
- if: matrix.target == 'built' || github.event_name == 'pull_request'
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: dist
path: dist
- if: matrix.target == 'built' || github.event_name == 'pull_request'
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: action.yml
path: .
@@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v7
with:
name: dist
path: dist
+75 -75
View File
@@ -4,10 +4,10 @@ import {
getWorkingBaseAndType,
buildBranchCommits
} from '../lib/create-or-update-branch'
import {randomUUID} from 'crypto'
import * as fs from 'fs'
import {GitCommandManager} from '../lib/git-command-manager'
import * as path from 'path'
import {v4 as uuidv4} from 'uuid'
const REPO_PATH = '/git/local/repos/test-base'
const REMOTE_NAME = 'origin'
@@ -31,7 +31,7 @@ const ADD_PATHS_MULTI = ['a', 'b']
const ADD_PATHS_WILDCARD = ['a/*.txt', 'b/*.txt']
async function createFile(filename: string, content?: string): Promise<string> {
const _content = content ? content : randomUUID()
const _content = content ? content : uuidv4()
const filepath = path.join(REPO_PATH, filename)
await fs.promises.mkdir(path.dirname(filepath), {recursive: true})
await fs.promises.writeFile(filepath, _content, {encoding: 'utf8'})
@@ -82,7 +82,7 @@ async function createCommits(
} else {
result.changes = await createChanges()
}
const commitMessage = randomUUID()
const commitMessage = uuidv4()
await git.exec(['add', '-A'])
await git.commit(['-m', commitMessage])
result.commitMsgs.unshift(commitMessage)
@@ -313,7 +313,7 @@ describe('create-or-update-branch tests', () => {
})
it('tests no changes resulting in no new branch being created', async () => {
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -330,7 +330,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with a tracked file change', async () => {
// Create a tracked file change
const trackedContent = await createFile(TRACKED_FILE)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -359,7 +359,7 @@ describe('create-or-update-branch tests', () => {
// Create a tracked file change
const _trackedContent = await createFile(TRACKED_FILE)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -381,7 +381,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with an untracked file change', async () => {
// Create an untracked file change
const untrackedContent = await createFile(UNTRACKED_FILE)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -410,7 +410,7 @@ describe('create-or-update-branch tests', () => {
// Create an untracked file change
const _untrackedContent = await createFile(UNTRACKED_FILE)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -434,7 +434,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -464,7 +464,7 @@ describe('create-or-update-branch tests', () => {
// Create identical tracked and untracked file changes
await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -486,7 +486,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with commits on the base inbetween', async () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -524,7 +524,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -556,7 +556,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -585,7 +585,7 @@ describe('create-or-update-branch tests', () => {
await beforeTest()
// Running with no update effectively reverts the branch back to match the base
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -607,7 +607,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -648,7 +648,7 @@ describe('create-or-update-branch tests', () => {
commits.changes.tracked,
commits.changes.untracked
)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -673,7 +673,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -703,7 +703,7 @@ describe('create-or-update-branch tests', () => {
// Create a commit on the base with a partial merge of the changes
await createFile(TRACKED_FILE, changes.tracked)
const baseCommitMessage = randomUUID()
const baseCommitMessage = uuidv4()
await git.exec(['add', '-A'])
await git.commit(['-m', baseCommitMessage])
await git.push([
@@ -714,7 +714,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -746,7 +746,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -790,7 +790,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes (no change on update)
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -816,7 +816,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -845,7 +845,7 @@ describe('create-or-update-branch tests', () => {
await beforeTest()
// Force push the base branch to a different commit
const amendedCommitMessage = randomUUID()
const amendedCommitMessage = uuidv4()
await git.commit(['--amend', '-m', amendedCommitMessage])
await git.push([
'--force',
@@ -855,7 +855,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes (no change on update)
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -878,7 +878,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with commits on the working base (during the workflow)', async () => {
// Create commits on the working base
const commits = await createCommits(git)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -910,7 +910,7 @@ describe('create-or-update-branch tests', () => {
// Create commits on the working base
const _commits = await createCommits(git)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -937,7 +937,7 @@ describe('create-or-update-branch tests', () => {
const commits = await createCommits(git)
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -973,7 +973,7 @@ describe('create-or-update-branch tests', () => {
const _commits = await createCommits(git)
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1002,7 +1002,7 @@ describe('create-or-update-branch tests', () => {
const commits = await createCommits(git)
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1046,7 +1046,7 @@ describe('create-or-update-branch tests', () => {
const _commits = await createCommits(git)
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1074,7 +1074,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update using a different remote from the base', async () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1104,7 +1104,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1127,7 +1127,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with signoff on commit', async () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1164,7 +1164,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1194,7 +1194,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with multiple add-paths', async () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1224,7 +1224,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1247,7 +1247,7 @@ describe('create-or-update-branch tests', () => {
it('tests create and update with wildcard add-paths', async () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1277,7 +1277,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1300,7 +1300,7 @@ describe('create-or-update-branch tests', () => {
it('tests create with add-paths resolving to no changes when other changes exist', async () => {
// Create tracked and untracked file changes
await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1321,7 +1321,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const resultA = await createOrUpdateBranch(
git,
commitMessage,
@@ -1365,7 +1365,7 @@ describe('create-or-update-branch tests', () => {
// Set the working base to a branch that is not the pull request base
await git.checkout(NOT_BASE_BRANCH)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1386,7 +1386,7 @@ describe('create-or-update-branch tests', () => {
// Create a tracked file change
const trackedContent = await createFile(TRACKED_FILE)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1418,7 +1418,7 @@ describe('create-or-update-branch tests', () => {
// Create a tracked file change
const _trackedContent = await createFile(TRACKED_FILE)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1443,7 +1443,7 @@ describe('create-or-update-branch tests', () => {
// Create an untracked file change
const untrackedContent = await createFile(UNTRACKED_FILE)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1475,7 +1475,7 @@ describe('create-or-update-branch tests', () => {
// Create an untracked file change
const _untrackedContent = await createFile(UNTRACKED_FILE)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1502,7 +1502,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1535,7 +1535,7 @@ describe('create-or-update-branch tests', () => {
// Create identical tracked and untracked file changes
await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1560,7 +1560,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1601,7 +1601,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1635,7 +1635,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1667,7 +1667,7 @@ describe('create-or-update-branch tests', () => {
await git.checkout(NOT_BASE_BRANCH)
// Running with no update effectively reverts the branch back to match the base
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1694,7 +1694,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1738,7 +1738,7 @@ describe('create-or-update-branch tests', () => {
commits.changes.tracked,
commits.changes.untracked
)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1768,7 +1768,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1798,7 +1798,7 @@ describe('create-or-update-branch tests', () => {
// Create a commit on the base with a partial merge of the changes
await createFile(TRACKED_FILE, changes.tracked)
const baseCommitMessage = randomUUID()
const baseCommitMessage = uuidv4()
await git.exec(['add', '-A'])
await git.commit(['-m', baseCommitMessage])
await git.push([
@@ -1812,7 +1812,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1846,7 +1846,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1893,7 +1893,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes (no change on update)
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1924,7 +1924,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -1953,7 +1953,7 @@ describe('create-or-update-branch tests', () => {
await beforeTest()
// Force push the base branch to a different commit
const amendedCommitMessage = randomUUID()
const amendedCommitMessage = uuidv4()
await git.commit(['--amend', '-m', amendedCommitMessage])
await git.push([
'--force',
@@ -1966,7 +1966,7 @@ describe('create-or-update-branch tests', () => {
// Create the same tracked and untracked file changes (no change on update)
const _changes = await createChanges(changes.tracked, changes.untracked)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -1992,7 +1992,7 @@ describe('create-or-update-branch tests', () => {
// Create commits on the working base
const commits = await createCommits(git)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2027,7 +2027,7 @@ describe('create-or-update-branch tests', () => {
// Create commits on the working base
const _commits = await createCommits(git)
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2057,7 +2057,7 @@ describe('create-or-update-branch tests', () => {
const commits = await createCommits(git)
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2096,7 +2096,7 @@ describe('create-or-update-branch tests', () => {
const _commits = await createCommits(git)
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2128,7 +2128,7 @@ describe('create-or-update-branch tests', () => {
const commits = await createCommits(git)
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2175,7 +2175,7 @@ describe('create-or-update-branch tests', () => {
const _commits = await createCommits(git)
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2205,7 +2205,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2238,7 +2238,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2268,7 +2268,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2302,7 +2302,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2329,7 +2329,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2371,7 +2371,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const _changes = await createChanges()
const _commitMessage = randomUUID()
const _commitMessage = uuidv4()
const _result = await createOrUpdateBranch(
git,
_commitMessage,
@@ -2403,7 +2403,7 @@ describe('create-or-update-branch tests', () => {
// Create commits on the working base
const commits = await createCommits(git)
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const result = await createOrUpdateBranch(
git,
commitMessage,
@@ -2428,7 +2428,7 @@ describe('create-or-update-branch tests', () => {
// Create tracked and untracked file changes
const changes = await createChanges()
const commitMessage = randomUUID()
const commitMessage = uuidv4()
const resultA = await createOrUpdateBranch(
git,
commitMessage,
+82 -5
View File
@@ -1,5 +1,7 @@
import {GitCommandManager} from '../lib/git-command-manager'
import {GitConfigHelper} from '../lib/git-config-helper'
import * as fs from 'fs'
import * as path from 'path'
const REPO_PATH = '/git/local/repos/test-base'
@@ -7,29 +9,104 @@ const extraheaderConfigKey = 'http.https://127.0.0.1/.extraheader'
describe('git-config-helper integration tests', () => {
let git: GitCommandManager
let originalRunnerTemp: string | undefined
beforeAll(async () => {
git = await GitCommandManager.create(REPO_PATH)
})
beforeEach(async () => {
// Save original RUNNER_TEMP
originalRunnerTemp = process.env['RUNNER_TEMP']
// Create a temp directory for tests
const tempDir = await fs.promises.mkdtemp('/tmp/cpr-test-')
process.env['RUNNER_TEMP'] = tempDir
process.env['GITHUB_WORKSPACE'] = REPO_PATH
})
afterEach(async () => {
// Clean up RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP']
if (runnerTemp && runnerTemp.startsWith('/tmp/cpr-test-')) {
await fs.promises.rm(runnerTemp, {recursive: true, force: true})
}
// Restore original RUNNER_TEMP
if (originalRunnerTemp !== undefined) {
process.env['RUNNER_TEMP'] = originalRunnerTemp
} else {
delete process.env['RUNNER_TEMP']
}
})
it('tests save and restore with no persisted auth', async () => {
const gitConfigHelper = await GitConfigHelper.create(git)
await gitConfigHelper.close()
})
it('tests configure and removal of auth', async () => {
it('tests configure and removal of auth using credentials file', async () => {
const runnerTemp = process.env['RUNNER_TEMP']!
const gitConfigHelper = await GitConfigHelper.create(git)
await gitConfigHelper.configureToken('github-token')
expect(await git.configExists(extraheaderConfigKey)).toBeTruthy()
expect(await git.getConfigValue(extraheaderConfigKey)).toEqual(
// Verify credentials file was created in RUNNER_TEMP
const files = await fs.promises.readdir(runnerTemp)
const credentialsFiles = files.filter(
f => f.startsWith('git-credentials-') && f.endsWith('.config')
)
expect(credentialsFiles.length).toBe(1)
// Verify credentials file contains the auth token
const credentialsPath = path.join(runnerTemp, credentialsFiles[0])
const credentialsContent = await fs.promises.readFile(
credentialsPath,
'utf8'
)
expect(credentialsContent).toContain(
'AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2l0aHViLXRva2Vu'
)
// Verify includeIf entries were added to local config
const includeIfKeys = await git.tryGetConfigKeys('^includeIf\\.gitdir:')
expect(includeIfKeys.length).toBeGreaterThan(0)
// Count credential includes pointing to this action's credentials file
let credentialIncludesForThisAction = 0
for (const key of includeIfKeys) {
const values = await git.tryGetConfigValues(key)
for (const value of values) {
if (value === credentialsPath) {
credentialIncludesForThisAction++
}
}
}
expect(credentialIncludesForThisAction).toBeGreaterThan(0)
await gitConfigHelper.close()
expect(await git.configExists(extraheaderConfigKey)).toBeFalsy()
// Verify credentials file was removed
const filesAfter = await fs.promises.readdir(runnerTemp)
const credentialsFilesAfter = filesAfter.filter(
f => f.startsWith('git-credentials-') && f.endsWith('.config')
)
expect(credentialsFilesAfter.length).toBe(0)
// Verify includeIf entries pointing to our specific credentials file were removed
const includeIfKeysAfter = await git.tryGetConfigKeys(
'^includeIf\\.gitdir:'
)
let credentialIncludesForThisActionAfter = 0
for (const key of includeIfKeysAfter) {
const values = await git.tryGetConfigValues(key)
for (const value of values) {
if (value === credentialsPath) {
credentialIncludesForThisActionAfter++
}
}
}
expect(credentialIncludesForThisActionAfter).toBe(0)
})
it('tests save and restore of persisted auth', async () => {
it('tests save and restore of persisted auth (old-style)', async () => {
const extraheaderConfigValue = 'AUTHORIZATION: basic ***persisted-auth***'
await git.config(extraheaderConfigKey, extraheaderConfigValue)
-68
View File
@@ -118,71 +118,3 @@ describe('utils tests', () => {
}
})
})
describe('retryWithBackoff', () => {
const makeConsistencyError = () => {
const error = new Error(
'Validation Failed: "Could not resolve to a node with the global id of \'PR_abc123\'."'
)
;(error as any).status = 422
return error
}
const shouldRetry = (e: unknown): boolean =>
e instanceof Error &&
(e as any).status === 422 &&
e.message.includes('Could not resolve to a node')
test('succeeds on first attempt without retrying', async () => {
const fn = jest.fn().mockResolvedValue('success')
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(1)
})
test('retries on eventual consistency 422 and succeeds', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(makeConsistencyError())
.mockResolvedValue('success')
const result = await utils.retryWithBackoff(fn, shouldRetry, 2, 1)
expect(result).toBe('success')
expect(fn).toHaveBeenCalledTimes(2)
})
test('exhausts retries on persistent 422 and throws', async () => {
const fn = jest.fn().mockRejectedValue(makeConsistencyError())
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
'Could not resolve to a node'
)
expect(fn).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
})
test('does not retry on non-422 errors', async () => {
const error = new Error('Forbidden')
;(error as any).status = 403
const fn = jest.fn().mockRejectedValue(error)
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
'Forbidden'
)
expect(fn).toHaveBeenCalledTimes(1)
})
test('does not retry on 422 without the consistency error message', async () => {
const error = new Error('Validation Failed: invalid label')
;(error as any).status = 422
const fn = jest.fn().mockRejectedValue(error)
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
'Validation Failed: invalid label'
)
expect(fn).toHaveBeenCalledTimes(1)
})
test('does not retry on plain Error objects', async () => {
const fn = jest.fn().mockRejectedValue(new Error('Something broke'))
await expect(utils.retryWithBackoff(fn, shouldRetry, 2, 1)).rejects.toThrow(
'Something broke'
)
expect(fn).toHaveBeenCalledTimes(1)
})
})
+899 -124
View File
File diff suppressed because it is too large Load Diff
+412 -841
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -39,9 +39,9 @@
"@octokit/plugin-rest-endpoint-methods": "^13.5.0",
"@octokit/plugin-retry": "^7.2.1",
"@octokit/plugin-throttling": "^9.6.1",
"@octokit/request-error": "^6.1.8",
"node-fetch-native": "^1.6.7",
"p-limit": "^6.2.0"
"p-limit": "^6.2.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -54,14 +54,14 @@
"eslint-plugin-github": "^4.10.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.4",
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"jest-environment-jsdom": "^30.3.0",
"jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.1",
"prettier": "^3.8.3",
"ts-jest": "^29.4.9",
"prettier": "^3.7.4",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"undici": "^6.25.0"
"undici": "^6.23.0"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import * as core from '@actions/core'
import {randomUUID} from 'crypto'
import {GitCommandManager, Commit} from './git-command-manager'
import {v4 as uuidv4} from 'uuid'
import * as utils from './utils'
const CHERRYPICK_EMPTY =
@@ -189,7 +189,7 @@ export async function createOrUpdateBranch(
const baseRemote = 'origin'
// Save the working base changes to a temporary branch
const tempBranch = randomUUID()
const tempBranch = uuidv4()
await git.checkout(tempBranch, 'HEAD')
// Commit any uncommitted changes
if (await git.isDirty(true, addPaths)) {
+69 -2
View File
@@ -96,9 +96,15 @@ export class GitCommandManager {
configKey: string,
configValue: string,
globalConfig?: boolean,
add?: boolean
add?: boolean,
configFile?: string
): Promise<void> {
const args: string[] = ['config', globalConfig ? '--global' : '--local']
const args: string[] = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
if (add) {
args.push('--add')
}
@@ -350,6 +356,67 @@ export class GitCommandManager {
return output.exitCode === 0
}
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--fixed-value', '--unset', configKey, configValue)
const output = await this.exec(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--get-all', configKey)
const output = await this.exec(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim())
}
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.exec(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim())
}
async tryGetRemoteUrl(): Promise<string> {
const output = await this.exec(
['config', '--local', '--get', 'remote.origin.url'],
+194 -74
View File
@@ -4,6 +4,7 @@ import {GitCommandManager} from './git-command-manager'
import * as path from 'path'
import {URL} from 'url'
import * as utils from './utils'
import {v4 as uuid} from 'uuid'
interface GitRemote {
hostname: string
@@ -13,7 +14,6 @@ interface GitRemote {
export class GitConfigHelper {
private git: GitCommandManager
private gitConfigPath = ''
private workingDirectory: string
private safeDirectoryConfigKey = 'safe.directory'
private safeDirectoryAdded = false
@@ -22,7 +22,8 @@ export class GitConfigHelper {
private extraheaderConfigPlaceholderValue = 'AUTHORIZATION: basic ***'
private extraheaderConfigValueRegex = '^AUTHORIZATION:'
private persistedExtraheaderConfigValue = ''
private backedUpCredentialFiles: string[] = []
// Path to the credentials config file in RUNNER_TEMP (new v6-style auth)
private credentialsConfigPath = ''
private constructor(git: GitCommandManager) {
this.git = git
@@ -122,15 +123,14 @@ export class GitConfigHelper {
async savePersistedAuth(): Promise<void> {
const serverUrl = new URL(`https://${this.getGitRemote().hostname}`)
this.extraheaderConfigKey = `http.${serverUrl.origin}/.extraheader`
// Backup checkout@v6 credential files if they exist
await this.hideCredentialFiles()
// Save and unset persisted extraheader credential in git config if it exists
// Save and unset persisted extraheader credential in git config if it exists (old-style auth)
// Note: checkout@v6 uses credentials files with includeIf, so we don't need to
// manipulate those - they work independently via git's include mechanism
this.persistedExtraheaderConfigValue = await this.getAndUnset()
}
async restorePersistedAuth(): Promise<void> {
// Restore checkout@v6 credential files if they were backed up
await this.unhideCredentialFiles()
// Restore old-style extraheader config if it was persisted
if (this.persistedExtraheaderConfigValue) {
try {
await this.setExtraheaderConfig(this.persistedExtraheaderConfigValue)
@@ -142,81 +142,218 @@ export class GitConfigHelper {
}
async configureToken(token: string): Promise<void> {
// Encode and configure the basic credential for HTTPS access
// Encode the basic credential for HTTPS access
const basicCredential = Buffer.from(
`x-access-token:${token}`,
'utf8'
).toString('base64')
core.setSecret(basicCredential)
const extraheaderConfigValue = `AUTHORIZATION: basic ${basicCredential}`
await this.setExtraheaderConfig(extraheaderConfigValue)
// Get or create the credentials config file path
const credentialsConfigPath = this.getCredentialsConfigPath()
// Write placeholder to the separate credentials config file using git config.
// This approach avoids the credential being captured by process creation audit events,
// which are commonly logged. For more information, refer to
// https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
await this.git.config(
this.extraheaderConfigKey,
this.extraheaderConfigPlaceholderValue,
false, // globalConfig
false, // add
credentialsConfigPath
)
// Replace the placeholder in the credentials config file
let content = (await fs.promises.readFile(credentialsConfigPath)).toString()
const placeholderIndex = content.indexOf(
this.extraheaderConfigPlaceholderValue
)
if (
placeholderIndex < 0 ||
placeholderIndex !=
content.lastIndexOf(this.extraheaderConfigPlaceholderValue)
) {
throw new Error(
`Unable to replace auth placeholder in ${credentialsConfigPath}`
)
}
content = content.replace(
this.extraheaderConfigPlaceholderValue,
extraheaderConfigValue
)
await fs.promises.writeFile(credentialsConfigPath, content)
// Configure includeIf entries to reference the credentials config file
await this.configureIncludeIf(credentialsConfigPath)
}
async removeAuth(): Promise<void> {
// Remove old-style extraheader config if it exists
await this.getAndUnset()
// Remove includeIf entries that point to git-credentials-*.config files
// and clean up the credentials config files
await this.removeIncludeIfCredentials()
}
/**
* Gets or creates the path to the credentials config file in RUNNER_TEMP.
* @returns The absolute path to the credentials config file
*/
private getCredentialsConfigPath(): string {
if (this.credentialsConfigPath) {
return this.credentialsConfigPath
}
const runnerTemp = process.env['RUNNER_TEMP'] || ''
if (!runnerTemp) {
throw new Error('RUNNER_TEMP is not defined')
}
// Create a unique filename for this action instance
const configFileName = `git-credentials-${uuid()}.config`
this.credentialsConfigPath = path.join(runnerTemp, configFileName)
core.debug(`Credentials config path: ${this.credentialsConfigPath}`)
return this.credentialsConfigPath
}
/**
* Configures includeIf entries in the local git config to reference the credentials file.
* Sets up entries for both host and container paths to support Docker container actions.
*/
private async configureIncludeIf(
credentialsConfigPath: string
): Promise<void> {
// Host git directory
const gitDir = await this.git.getGitDirectory()
let hostGitDir = path.join(this.workingDirectory, gitDir)
hostGitDir = hostGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
// Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${hostGitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `includeIf.gitdir:${hostGitDir}/worktrees/*.path`
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
// Container paths for Docker container actions
const githubWorkspace = process.env['GITHUB_WORKSPACE']
if (githubWorkspace) {
let relativePath = path.relative(githubWorkspace, this.workingDirectory)
relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows
const containerGitDir = path.posix.join(
'/github/workspace',
relativePath,
'.git'
)
// Container credentials config path
const containerCredentialsPath = path.posix.join(
'/github/runner_temp',
path.basename(credentialsConfigPath)
)
// Configure container includeIf
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
await this.git.config(containerIncludeKey, containerCredentialsPath)
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`
await this.git.config(
containerWorktreeIncludeKey,
containerCredentialsPath
)
}
}
/**
* Removes the includeIf entry and credentials config file created by this action instance.
* Only cleans up the specific credentials file tracked in this.credentialsConfigPath,
* leaving credentials created by other actions (e.g., actions/checkout) intact.
*/
private async removeIncludeIfCredentials(): Promise<void> {
// Only clean up if this action instance created a credentials config file
if (!this.credentialsConfigPath) {
return
}
try {
// Get all includeIf.gitdir keys from local config
const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:')
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(key)
for (const value of values) {
// Only remove entries pointing to our specific credentials file
if (value === this.credentialsConfigPath) {
await this.git.tryConfigUnsetValue(key, value)
core.debug(`Removed includeIf entry: ${key} = ${value}`)
}
}
}
} catch (e) {
// Ignore errors during cleanup
core.debug(`Error during includeIf cleanup: ${utils.getErrorMessage(e)}`)
}
// Delete only our credentials config file
const runnerTemp = process.env['RUNNER_TEMP']
const resolvedCredentialsPath = path.resolve(this.credentialsConfigPath)
const resolvedRunnerTemp = runnerTemp ? path.resolve(runnerTemp) : ''
if (
resolvedRunnerTemp &&
resolvedCredentialsPath.startsWith(resolvedRunnerTemp + path.sep)
) {
try {
await fs.promises.unlink(this.credentialsConfigPath)
core.info(
`Removed credentials config file: ${this.credentialsConfigPath}`
)
} catch (e) {
core.debug(
`Could not remove credentials file ${this.credentialsConfigPath}: ${utils.getErrorMessage(e)}`
)
}
}
}
/**
* Sets extraheader config directly in .git/config (old-style auth).
* Used only for restoring persisted credentials from checkout@v4/v5.
*/
private async setExtraheaderConfig(
extraheaderConfigValue: string
): Promise<void> {
// Configure a placeholder value. This approach avoids the credential being captured
// by process creation audit events, which are commonly logged. For more information,
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
// See https://github.com/actions/checkout/blob/main/src/git-auth-helper.ts#L267-L274
await this.git.config(
this.extraheaderConfigKey,
this.extraheaderConfigPlaceholderValue
)
// Replace the placeholder
await this.gitConfigStringReplace(
// Replace the placeholder in the local git config
const gitDir = await this.git.getGitDirectory()
const gitConfigPath = path.join(this.workingDirectory, gitDir, 'config')
let content = (await fs.promises.readFile(gitConfigPath)).toString()
const index = content.indexOf(this.extraheaderConfigPlaceholderValue)
if (
index < 0 ||
index != content.lastIndexOf(this.extraheaderConfigPlaceholderValue)
) {
throw new Error(
`Unable to replace '${this.extraheaderConfigPlaceholderValue}' in ${gitConfigPath}`
)
}
content = content.replace(
this.extraheaderConfigPlaceholderValue,
extraheaderConfigValue
)
}
private async hideCredentialFiles(): Promise<void> {
// Temporarily hide checkout@v6 credential files to avoid duplicate auth headers
const runnerTemp = process.env['RUNNER_TEMP']
if (!runnerTemp) {
return
}
try {
const files = await fs.promises.readdir(runnerTemp)
for (const file of files) {
if (file.startsWith('git-credentials-') && file.endsWith('.config')) {
const sourcePath = path.join(runnerTemp, file)
const backupPath = `${sourcePath}.bak`
await fs.promises.rename(sourcePath, backupPath)
this.backedUpCredentialFiles.push(backupPath)
core.info(
`Temporarily hiding checkout credential file: ${file} (will be restored after)`
)
}
}
} catch (e) {
// If directory doesn't exist or we can't read it, just continue
core.debug(
`Could not backup credential files: ${utils.getErrorMessage(e)}`
)
}
}
private async unhideCredentialFiles(): Promise<void> {
// Restore checkout@v6 credential files that were backed up
for (const backupPath of this.backedUpCredentialFiles) {
try {
const originalPath = backupPath.replace(/\.bak$/, '')
await fs.promises.rename(backupPath, originalPath)
const fileName = path.basename(originalPath)
core.info(`Restored checkout credential file: ${fileName}`)
} catch (e) {
core.warning(
`Failed to restore credential file ${backupPath}: ${utils.getErrorMessage(e)}`
)
}
}
this.backedUpCredentialFiles = []
await fs.promises.writeFile(gitConfigPath, content)
}
private async getAndUnset(): Promise<string> {
@@ -247,21 +384,4 @@ export class GitConfigHelper {
}
return configValue
}
private async gitConfigStringReplace(
find: string,
replace: string
): Promise<void> {
if (this.gitConfigPath.length === 0) {
const gitDir = await this.git.getGitDirectory()
this.gitConfigPath = path.join(this.workingDirectory, gitDir, 'config')
}
let content = (await fs.promises.readFile(this.gitConfigPath)).toString()
const index = content.indexOf(find)
if (index < 0 || index != content.lastIndexOf(find)) {
throw new Error(`Unable to replace '${find}' in ${this.gitConfigPath}`)
}
content = content.replace(find, replace)
await fs.promises.writeFile(this.gitConfigPath, content)
}
}
+20 -42
View File
@@ -1,5 +1,4 @@
import * as core from '@actions/core'
import {RequestError} from '@octokit/request-error'
import {Inputs} from './create-pull-request'
import {Commit, GitCommandManager} from './git-command-manager'
import {
@@ -210,51 +209,32 @@ export class GitHubHelper {
headRepository
)
// After creating a new PR, follow-up API calls can fail with a 422
// "Could not resolve to a node" error due to GitHub API eventual
// consistency. Wrap post-creation calls with targeted retry logic.
// See: https://github.com/peter-evans/create-pull-request/issues/4321
const isEventualConsistencyError = (e: unknown): boolean =>
e instanceof RequestError &&
e.status === 422 &&
e.message.includes('Could not resolve to a node')
const withRetryForNewPr = <T>(fn: () => Promise<T>): Promise<T> =>
pull.created
? utils.retryWithBackoff(fn, isEventualConsistencyError)
: fn()
// Apply milestone
if (inputs.milestone) {
core.info(`Applying milestone '${inputs.milestone}'`)
await withRetryForNewPr(() =>
this.octokit.rest.issues.update({
...this.parseRepository(baseRepository),
issue_number: pull.number,
milestone: inputs.milestone
})
)
await this.octokit.rest.issues.update({
...this.parseRepository(baseRepository),
issue_number: pull.number,
milestone: inputs.milestone
})
}
// Apply labels
if (inputs.labels.length > 0) {
core.info(`Applying labels '${inputs.labels}'`)
await withRetryForNewPr(() =>
this.octokit.rest.issues.addLabels({
...this.parseRepository(baseRepository),
issue_number: pull.number,
labels: inputs.labels
})
)
await this.octokit.rest.issues.addLabels({
...this.parseRepository(baseRepository),
issue_number: pull.number,
labels: inputs.labels
})
}
// Apply assignees
if (inputs.assignees.length > 0) {
core.info(`Applying assignees '${inputs.assignees}'`)
await withRetryForNewPr(() =>
this.octokit.rest.issues.addAssignees({
...this.parseRepository(baseRepository),
issue_number: pull.number,
assignees: inputs.assignees
})
)
await this.octokit.rest.issues.addAssignees({
...this.parseRepository(baseRepository),
issue_number: pull.number,
assignees: inputs.assignees
})
}
// Request reviewers and team reviewers
@@ -270,13 +250,11 @@ export class GitHubHelper {
}
if (Object.keys(requestReviewersParams).length > 0) {
try {
await withRetryForNewPr(() =>
this.octokit.rest.pulls.requestReviewers({
...this.parseRepository(baseRepository),
pull_number: pull.number,
...requestReviewersParams
})
)
await this.octokit.rest.pulls.requestReviewers({
...this.parseRepository(baseRepository),
pull_number: pull.number,
...requestReviewersParams
})
} catch (e) {
if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) {
core.error(
-24
View File
@@ -140,27 +140,3 @@ export const isSelfHosted = (): boolean =>
process.env['RUNNER_ENVIRONMENT'] !== 'github-hosted' &&
(process.env['AGENT_ISSELFHOSTED'] === '1' ||
process.env['AGENT_ISSELFHOSTED'] === undefined)
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
shouldRetry: (error: unknown) => boolean,
maxRetries = 2,
delayMs = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (e) {
if (attempt < maxRetries && shouldRetry(e)) {
const delay = delayMs * Math.pow(2, attempt)
core.info(
`Request failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...`
)
await new Promise(resolve => setTimeout(resolve, delay))
} else {
throw e
}
}
}
throw new Error('Unexpected: retry loop exited without return or throw')
}