1
0
mirror of https://github.com/actions/checkout.git synced 2026-06-23 17:43:52 +08:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Mark Vander Stel
82a19cdb0b Merge 1d3fa26c9e into c2d88d3ecc 2025-11-24 14:00:54 +01:00
Mark Vander Stel
1d3fa26c9e Fix checkout of annotated tag loosing annotation
Currently, a check is done after fetch to ensure that the repo state has
not changed since the workflow was triggered. This check will reset the
checkout to the commit that triggered the workflow, even if the branch
or tag has moved since.

The issue is that the check currently sees what "object" the ref points
to. For an annotated tag, that is the annotation, not the commit. This
means the check always fails for annotated tags, and they are reset to
the commit, losing the annotation. Losing the annotation can be fatal,
as `git describe` will only match annotated tags.

The fix is simple: check if the tag points at the right commit, ignoring
any other type of object. This is done with the <rev>^{commit} syntax.

From the git-rev-parse docs:
> <rev>^{<type>}, e.g. v0.99.8^{commit}
>  A suffix ^ followed by an object type name enclosed in brace pair
>  means dereference the object at <rev> recursively until an object of
>  type <type> is found or the object cannot be dereferenced anymore (in
>  which case, barf). For example, if <rev> is a commit-ish,
>  <rev>^{commit} describes the corresponding commit object. Similarly,
>  if <rev> is a tree-ish, <rev>^{tree} describes the corresponding tree
>  object.  <rev>^0 is a short-hand for <rev>^{commit}.

If the check still fails, we will still reset the tag to the commit,
losing the annotation. However, there is no way to truly recover in this
situtation, as GitHub does not capture the annotation on workflow start,
and since the history has changed, we can not trust the new tag to
contain the same data as it did before.

Fixes #290
Closes #697
2023-10-06 12:42:43 -04:00
15 changed files with 114 additions and 685 deletions

View File

@@ -87,17 +87,6 @@ jobs:
- name: Verify fetch filter
run: __test__/verify-fetch-filter.sh
# Fetch tags
- name: Checkout with fetch-tags
uses: ./
with:
ref: test-data/v2/basic
path: fetch-tags-test
fetch-tags: true
- name: Verify fetch-tags
shell: bash
run: __test__/verify-fetch-tags.sh
# Sparse checkout
- name: Sparse checkout
uses: ./
@@ -176,22 +165,6 @@ jobs:
- name: Verify submodules recursive
run: __test__/verify-submodules-recursive.sh
# Worktree credentials
- name: Checkout for worktree test
uses: ./
with:
path: worktree-test
- name: Verify worktree credentials
shell: bash
run: __test__/verify-worktree.sh worktree-test worktree-branch
# Worktree credentials in container step
- name: Verify worktree credentials in container step
if: runner.os == 'Linux'
uses: docker://bitnami/git:latest
with:
args: bash __test__/verify-worktree.sh worktree-test container-worktree-branch
# Basic checkout using REST API
- name: Remove basic
if: runner.os != 'windows'

View File

@@ -1,25 +1,19 @@
# Changelog
## v6.0.2
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356
## v6.0.1
* Add worktree support for persist-credentials includeIf by @ericsciple in https://github.com/actions/checkout/pull/2327
## v6.0.0
## V6.0.0
* Persist creds to a separate file by @ericsciple in https://github.com/actions/checkout/pull/2286
* Update README to include Node.js 24 support details and requirements by @salmanmkc in https://github.com/actions/checkout/pull/2248
## v5.0.1
## V5.0.1
* Port v6 cleanup to v5 by @ericsciple in https://github.com/actions/checkout/pull/2301
## v5.0.0
## V5.0.0
* Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
## v4.3.1
## V4.3.1
* Port v6 cleanup to v4 by @ericsciple in https://github.com/actions/checkout/pull/2305
## v4.3.0
## V4.3.0
* docs: update README.md by @motss in https://github.com/actions/checkout/pull/1971
* Add internal repos for checking out multiple repositories by @mouismail in https://github.com/actions/checkout/pull/1977
* Documentation update - add recommended permissions to Readme by @benwells in https://github.com/actions/checkout/pull/2043

View File

@@ -4,9 +4,8 @@
## What's new
- Improved credential security: `persist-credentials` now stores credentials in a separate file under `$RUNNER_TEMP` instead of directly in `.git/config`
- No workflow changes required — `git fetch`, `git push`, etc. continue to work automatically
- Running authenticated git commands from a [Docker container action](https://docs.github.com/actions/sharing-automations/creating-actions/creating-a-docker-container-action) requires Actions Runner [v2.329.0](https://github.com/actions/runner/releases/tag/v2.329.0) or later
- Updated `persist-credentials` to store the credentials under `$RUNNER_TEMP` instead of directly in the local git config.
- This requires a minimum Actions Runner version of [v2.329.0](https://github.com/actions/runner/releases/tag/v2.329.0) to access the persisted credentials for [Docker container action](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-a-docker-container-action) scenarios.
# Checkout v5

View File

@@ -974,46 +974,6 @@ describe('git-auth-helper tests', () => {
).toBe(false)
expect((authHelper as any).testCredentialsConfigPath('')).toBe(false)
})
const includeIfCleanupRegex_matchesBothVariants =
'includeIf cleanup regex matches both gitdir: and gitdir/i: keys'
it(includeIfCleanupRegex_matchesBothVariants, async () => {
// The cleanup regex must match both variants so credential
// removal works regardless of which was written
const regex = /^includeIf\.gitdir(\/i)?:/
expect(regex.test('includeIf.gitdir:D:/workspaces/repo/.git.path')).toBe(
true
)
expect(regex.test('includeIf.gitdir/i:D:/Workspaces/repo/.git.path')).toBe(
true
)
expect(regex.test('includeIf.gitdir/i:/github/workspace/.git.path')).toBe(
true
)
expect(regex.test('includeIf.gitdir:~/projects/foo/.git.path')).toBe(true)
expect(regex.test('includeIf.onbranch:main.path')).toBe(false)
expect(regex.test('include.path')).toBe(false)
})
const includeIfDirective_usesCorrectVariantForPlatform =
'includeIf directive uses gitdir/i on Windows and gitdir on other platforms'
it(includeIfDirective_usesCorrectVariantForPlatform, async () => {
await setup(includeIfDirective_usesCorrectVariantForPlatform)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
const localConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
if (isWindows) {
expect(localConfigContent).toContain('includeIf.gitdir/i:')
expect(localConfigContent).not.toContain('includeIf.gitdir:')
} else {
expect(localConfigContent).toContain('includeIf.gitdir:')
expect(localConfigContent).not.toContain('includeIf.gitdir/i:')
}
})
})
async function setup(testName: string): Promise<void> {

View File

@@ -108,7 +108,7 @@ describe('Test fetchDepth and fetchTags options', () => {
jest.restoreAllMocks()
})
it('should call execGit with the correct arguments when fetchDepth is 0', async () => {
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is true', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
@@ -122,7 +122,45 @@ describe('Test fetchDepth and fetchTags options', () => {
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchDepth: 0
fetchDepth: 0,
fetchTags: true
}
await git.fetch(refSpec, options)
expect(mockExec).toHaveBeenCalledWith(
expect.any(String),
[
'-c',
'protocol.version=2',
'fetch',
'--prune',
'--no-recurse-submodules',
'--filter=filterValue',
'origin',
'refspec1',
'refspec2'
],
expect.any(Object)
)
})
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is false', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
const doSparseCheckout = false
git = await commandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchDepth: 0,
fetchTags: false
}
await git.fetch(refSpec, options)
@@ -145,45 +183,7 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
const doSparseCheckout = false
git = await commandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
const options = {
filter: 'filterValue',
fetchDepth: 0
}
await git.fetch(refSpec, options)
expect(mockExec).toHaveBeenCalledWith(
expect.any(String),
[
'-c',
'protocol.version=2',
'fetch',
'--no-tags',
'--prune',
'--no-recurse-submodules',
'--filter=filterValue',
'origin',
'refspec1',
'refspec2',
'+refs/tags/*:refs/tags/*'
],
expect.any(Object)
)
})
it('should call execGit with the correct arguments when fetchDepth is 1', async () => {
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is false', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -197,7 +197,8 @@ describe('Test fetchDepth and fetchTags options', () => {
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchDepth: 1
fetchDepth: 1,
fetchTags: false
}
await git.fetch(refSpec, options)
@@ -221,7 +222,7 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when fetchDepth is 1 and refSpec includes tags', async () => {
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is true', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -232,10 +233,11 @@ describe('Test fetchDepth and fetchTags options', () => {
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchDepth: 1
fetchDepth: 1,
fetchTags: true
}
await git.fetch(refSpec, options)
@@ -246,15 +248,13 @@ describe('Test fetchDepth and fetchTags options', () => {
'-c',
'protocol.version=2',
'fetch',
'--no-tags',
'--prune',
'--no-recurse-submodules',
'--filter=filterValue',
'--depth=1',
'origin',
'refspec1',
'refspec2',
'+refs/tags/*:refs/tags/*'
'refspec2'
],
expect.any(Object)
)
@@ -338,7 +338,7 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when showProgress is true and refSpec includes tags', async () => {
it('should call execGit with the correct arguments when fetchTags is true and showProgress is true', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -349,9 +349,10 @@ describe('Test fetchDepth and fetchTags options', () => {
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchTags: true,
showProgress: true
}
@@ -363,134 +364,15 @@ describe('Test fetchDepth and fetchTags options', () => {
'-c',
'protocol.version=2',
'fetch',
'--no-tags',
'--prune',
'--no-recurse-submodules',
'--progress',
'--filter=filterValue',
'origin',
'refspec1',
'refspec2',
'+refs/tags/*:refs/tags/*'
'refspec2'
],
expect.any(Object)
)
})
})
describe('git user-agent with orchestration ID', () => {
beforeEach(async () => {
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
})
afterEach(() => {
jest.restoreAllMocks()
// Clean up environment variable to prevent test pollution
delete process.env['ACTIONS_ORCHESTRATION_ID']
})
it('should include orchestration ID in user-agent when ACTIONS_ORCHESTRATION_ID is set', async () => {
const orchId = 'test-orch-id-12345'
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
let capturedEnv: any = null
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18'))
}
// Capture env on any command
capturedEnv = options.env
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
const doSparseCheckout = false
git = await commandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
// Call a git command to trigger env capture after user-agent is set
await git.init()
// Verify the user agent includes the orchestration ID
expect(git).toBeDefined()
expect(capturedEnv).toBeDefined()
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
`git/2.18 (github-actions-checkout) actions_orchestration_id/${orchId}`
)
})
it('should sanitize invalid characters in orchestration ID', async () => {
const orchId = 'test (with) special/chars'
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
let capturedEnv: any = null
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18'))
}
// Capture env on any command
capturedEnv = options.env
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
const doSparseCheckout = false
git = await commandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
// Call a git command to trigger env capture after user-agent is set
await git.init()
// Verify the user agent has sanitized orchestration ID (spaces, parentheses, slash replaced)
expect(git).toBeDefined()
expect(capturedEnv).toBeDefined()
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
'git/2.18 (github-actions-checkout) actions_orchestration_id/test__with__special_chars'
)
})
it('should not modify user-agent when ACTIONS_ORCHESTRATION_ID is not set', async () => {
delete process.env['ACTIONS_ORCHESTRATION_ID']
let capturedEnv: any = null
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18'))
}
// Capture env on any command
capturedEnv = options.env
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
const doSparseCheckout = false
git = await commandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
// Call a git command to trigger env capture after user-agent is set
await git.init()
// Verify the user agent does NOT contain orchestration ID
expect(git).toBeDefined()
expect(capturedEnv).toBeDefined()
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
'git/2.18 (github-actions-checkout)'
)
})
})

View File

@@ -133,16 +133,6 @@ describe('input-helper tests', () => {
expect(settings.commit).toBe('1111111111222222222233333333334444444444')
})
it('sets ref to empty when explicit sha-256', async () => {
inputs.ref =
'1111111111222222222233333333334444444444555555555566666666667777'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.ref).toBeFalsy()
expect(settings.commit).toBe(
'1111111111222222222233333333334444444444555555555566666666667777'
)
})
it('sets sha to empty when explicit ref', async () => {
inputs.ref = 'refs/heads/some-other-ref'
const settings: IGitSourceSettings = await inputHelper.getInputs()

View File

@@ -1,12 +1,8 @@
import * as assert from 'assert'
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as refHelper from '../lib/ref-helper'
import {IGitCommandManager} from '../lib/git-command-manager'
const commit = '1234567890123456789012345678901234567890'
const sha256Commit =
'1234567890123456789012345678901234567890123456789012345678901234'
let git: IGitCommandManager
describe('ref-helper tests', () => {
@@ -41,12 +37,6 @@ describe('ref-helper tests', () => {
expect(checkoutInfo.startPoint).toBeFalsy()
})
it('getCheckoutInfo sha-256 only', async () => {
const checkoutInfo = await refHelper.getCheckoutInfo(git, '', sha256Commit)
expect(checkoutInfo.ref).toBe(sha256Commit)
expect(checkoutInfo.startPoint).toBeFalsy()
})
it('getCheckoutInfo refs/heads/', async () => {
const checkoutInfo = await refHelper.getCheckoutInfo(
git,
@@ -162,22 +152,7 @@ describe('ref-helper tests', () => {
it('getRefSpec sha + refs/tags/', async () => {
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe(`+refs/tags/my-tag:refs/tags/my-tag`)
})
it('getRefSpec sha + refs/tags/ with fetchTags', async () => {
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit, true)
expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
})
it('getRefSpec sha + refs/heads/ with fetchTags', async () => {
// When fetchTags is true, include both the branch refspec and tags wildcard
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit, true)
expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
expect(refSpec[1]).toBe(`+${commit}:refs/remotes/origin/my/branch`)
expect(refSpec[0]).toBe(`+${commit}:refs/tags/my-tag`)
})
it('getRefSpec sha only', async () => {
@@ -193,14 +168,6 @@ describe('ref-helper tests', () => {
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
})
it('getRefSpec unqualified ref only with fetchTags', async () => {
// When fetchTags is true, skip specific tag pattern since wildcard covers all
const refSpec = refHelper.getRefSpec('my-ref', '', true)
expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
expect(refSpec[1]).toBe('+refs/heads/my-ref*:refs/remotes/origin/my-ref*')
})
it('getRefSpec refs/heads/ only', async () => {
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
expect(refSpec.length).toBe(1)
@@ -220,159 +187,4 @@ describe('ref-helper tests', () => {
expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
})
it('getRefSpec refs/tags/ only with fetchTags', async () => {
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '', true)
expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
})
it('getRefSpec refs/heads/ only with fetchTags', async () => {
// When fetchTags is true, include both the branch refspec and tags wildcard
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '', true)
expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
expect(refSpec[1]).toBe(
'+refs/heads/my/branch:refs/remotes/origin/my/branch'
)
})
describe('checkCommitInfo', () => {
const repositoryOwner = 'some-owner'
const repositoryName = 'some-repo'
const ref = 'refs/pull/123/merge'
const sha1Head = '1111111111222222222233333333334444444444'
const sha1Base = 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd'
const sha256Head =
'1111111111222222222233333333334444444444555555555566666666667777'
const sha256Base =
'aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffff0000'
let debugSpy: jest.SpyInstance
let getOctokitSpy: jest.SpyInstance
let repoGetSpy: jest.Mock
let originalEventName: string
let originalPayload: unknown
let originalRef: string
let originalSha: string
function setPullRequestContext(
expectedHeadSha: string,
expectedBaseSha: string,
mergeCommit: string
): void {
;(github.context as any).eventName = 'pull_request'
github.context.ref = ref
github.context.sha = mergeCommit
;(github.context as any).payload = {
action: 'synchronize',
after: expectedHeadSha,
number: 123,
pull_request: {
base: {
sha: expectedBaseSha
}
},
repository: {
private: false
}
}
}
beforeEach(() => {
originalEventName = github.context.eventName
originalPayload = github.context.payload
originalRef = github.context.ref
originalSha = github.context.sha
jest.spyOn(github.context, 'repo', 'get').mockReturnValue({
owner: repositoryOwner,
repo: repositoryName
})
debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn())
repoGetSpy = jest.fn(async () => ({}))
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: {
repos: {
get: repoGetSpy
}
}
} as any)
})
afterEach(() => {
;(github.context as any).eventName = originalEventName
;(github.context as any).payload = originalPayload
github.context.ref = originalRef
github.context.sha = originalSha
jest.restoreAllMocks()
})
it('returns early for SHA-1 merge commit', async () => {
setPullRequestContext(sha1Head, sha1Base, commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${sha1Head} into ${sha1Base}`,
repositoryOwner,
repositoryName,
ref,
commit
)
expect(getOctokitSpy).not.toHaveBeenCalled()
expect(repoGetSpy).not.toHaveBeenCalled()
})
it('matches SHA-256 merge commit info', async () => {
const actualHeadSha =
'9999999999888888888877777777776666666666555555555544444444443333'
setPullRequestContext(sha256Head, sha256Base, sha256Commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${actualHeadSha} into ${sha256Base}`,
repositoryOwner,
repositoryName,
ref,
sha256Commit
)
expect(getOctokitSpy).toHaveBeenCalledWith(
'token',
expect.objectContaining({
userAgent: expect.stringContaining(
`expected_head_sha=${sha256Head};actual_head_sha=${actualHeadSha}`
)
})
)
expect(repoGetSpy).toHaveBeenCalledWith({
owner: repositoryOwner,
repo: repositoryName
})
expect(debugSpy).toHaveBeenCalledWith(
`Expected head sha ${sha256Head}; actual head sha ${actualHeadSha}`
)
expect(debugSpy).not.toHaveBeenCalledWith('Unexpected message format')
})
it('does not match 50-char hex as a valid merge', async () => {
const invalidHeadSha =
'99999999998888888888777777777766666666665555555555'
setPullRequestContext(sha1Head, sha1Base, commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${invalidHeadSha} into ${sha1Base}`,
repositoryOwner,
repositoryName,
ref,
commit
)
expect(getOctokitSpy).not.toHaveBeenCalled()
expect(repoGetSpy).not.toHaveBeenCalled()
expect(debugSpy).toHaveBeenCalledWith('Unexpected message format')
})
})
})

View File

@@ -1,9 +0,0 @@
#!/bin/sh
# Verify tags were fetched
TAG_COUNT=$(git -C ./fetch-tags-test tag | wc -l)
if [ "$TAG_COUNT" -eq 0 ]; then
echo "Expected tags to be fetched, but found none"
exit 1
fi
echo "Found $TAG_COUNT tags"

View File

@@ -1,51 +0,0 @@
#!/bin/bash
set -e
# Verify worktree credentials
# This test verifies that git credentials work in worktrees created after checkout
# Usage: verify-worktree.sh <checkout-path> <worktree-name>
CHECKOUT_PATH="$1"
WORKTREE_NAME="$2"
if [ -z "$CHECKOUT_PATH" ] || [ -z "$WORKTREE_NAME" ]; then
echo "Usage: verify-worktree.sh <checkout-path> <worktree-name>"
exit 1
fi
cd "$CHECKOUT_PATH"
# Add safe directory for container environments
git config --global --add safe.directory "*" 2>/dev/null || true
# Show the includeIf configuration
echo "Git config includeIf entries:"
git config --list --show-origin | grep -i include || true
# Create the worktree
echo "Creating worktree..."
git worktree add "../$WORKTREE_NAME" HEAD --detach
# Change to worktree directory
cd "../$WORKTREE_NAME"
# Verify we're in a worktree
echo "Verifying worktree gitdir:"
cat .git
# Verify credentials are available in worktree by checking extraheader is configured
echo "Checking credentials in worktree..."
if git config --list --show-origin | grep -q "extraheader"; then
echo "Credentials are configured in worktree"
else
echo "ERROR: Credentials are NOT configured in worktree"
echo "Full git config:"
git config --list --show-origin
exit 1
fi
# Verify fetch works in the worktree
echo "Fetching in worktree..."
git fetch origin
echo "Worktree credentials test passed!"

101
dist/index.js vendored
View File

@@ -151,12 +151,6 @@ const stateHelper = __importStar(__nccwpck_require__(4866));
const urlHelper = __importStar(__nccwpck_require__(9437));
const uuid_1 = __nccwpck_require__(5840);
const IS_WINDOWS = process.platform === 'win32';
// Use case-insensitive gitdir matching on Windows to handle path casing mismatches
// between the runner's GITHUB_WORKSPACE and the actual filesystem casing.
// See: https://github.com/actions/checkout/issues/2345
const INCLUDE_IF_GITDIR = IS_WINDOWS
? 'includeIf.gitdir/i:'
: 'includeIf.gitdir:';
const SSH_COMMAND_KEY = 'core.sshCommand';
function createAuthHelper(git, settings) {
return new GitAuthHelper(git, settings);
@@ -276,7 +270,7 @@ class GitAuthHelper {
let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config
submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
// Configure host includeIf
yield this.git.config(`${INCLUDE_IF_GITDIR}${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig?
yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig?
false, // add?
configPath);
// Container submodule git directory
@@ -286,7 +280,7 @@ class GitAuthHelper {
relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir);
// Configure container includeIf
yield this.git.config(`${INCLUDE_IF_GITDIR}${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig?
yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig?
false, // add?
configPath);
}
@@ -416,11 +410,8 @@ class GitAuthHelper {
let gitDir = path.join(this.git.getWorkingDirectory(), '.git');
gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
// Configure host includeIf
const hostIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}.path`;
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
yield this.git.config(hostIncludeKey, credentialsConfigPath);
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}/worktrees/*.path`;
yield this.git.config(hostWorktreeIncludeKey, credentialsConfigPath);
// Container git directory
const workingDirectory = this.git.getWorkingDirectory();
const githubWorkspace = process.env['GITHUB_WORKSPACE'];
@@ -431,11 +422,8 @@ class GitAuthHelper {
// Container credentials config path
const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
// Configure container includeIf
const containerIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}.path`;
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`;
yield this.git.config(containerIncludeKey, containerCredentialsPath);
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}/worktrees/*.path`;
yield this.git.config(containerWorktreeIncludeKey, containerCredentialsPath);
}
});
}
@@ -571,7 +559,7 @@ class GitAuthHelper {
const credentialsPaths = new Set();
try {
// Get all includeIf.gitdir keys
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir(/i)?:', false, // globalConfig?
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
configPath);
for (const key of keys) {
// Get all values for this key
@@ -659,6 +647,7 @@ const fs = __importStar(__nccwpck_require__(7147));
const fshelper = __importStar(__nccwpck_require__(7219));
const io = __importStar(__nccwpck_require__(7436));
const path = __importStar(__nccwpck_require__(1017));
const refHelper = __importStar(__nccwpck_require__(8601));
const regexpHelper = __importStar(__nccwpck_require__(3120));
const retryHelper = __importStar(__nccwpck_require__(2155));
const git_version_1 = __nccwpck_require__(3142);
@@ -836,9 +825,9 @@ class GitCommandManager {
fetch(refSpec, options) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['-c', 'protocol.version=2', 'fetch'];
// Always use --no-tags for explicit control over tag fetching
// Tags are fetched explicitly via refspec when needed
args.push('--no-tags');
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
args.push('--no-tags');
}
args.push('--prune', '--no-recurse-submodules');
if (options.showProgress) {
args.push('--progress');
@@ -1211,17 +1200,7 @@ class GitCommandManager {
}
}
// Set the user agent
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`;
// Append orchestration ID if set
const orchId = process.env['ACTIONS_ORCHESTRATION_ID'];
if (orchId) {
// Sanitize the orchestration ID to ensure it contains only valid characters
// Valid characters: 0-9, a-z, _, -, .
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_');
if (sanitizedId) {
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`;
}
}
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`;
core.debug(`Set git useragent to: ${gitHttpUserAgent}`);
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent;
});
@@ -1544,26 +1523,13 @@ function getSource(settings) {
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
yield git.fetch(refSpec, fetchOptions);
// Verify the ref now matches. For branches, the targeted fetch above brings
// in the specific commit. For tags (fetched by ref), this will fail if
// the tag was moved after the workflow was triggered.
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
`The ref may have been updated after the workflow was triggered.`);
}
}
}
else {
fetchOptions.fetchDepth = settings.fetchDepth;
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit, settings.fetchTags);
fetchOptions.fetchTags = settings.fetchTags;
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
yield git.fetch(refSpec, fetchOptions);
// For tags, verify the ref still points to the expected commit.
// Tags are fetched by ref (not commit), so if a tag was moved after the
// workflow was triggered, we would silently check out the wrong commit.
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
`The ref may have been updated after the workflow was triggered.`);
}
}
core.endGroup();
// Checkout info
@@ -2027,7 +1993,7 @@ function getInputs() {
}
}
// SHA?
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = result.ref;
result.ref = '';
}
@@ -2302,67 +2268,53 @@ function getRefSpecForAllHistory(ref, commit) {
}
return result;
}
function getRefSpec(ref, commit, fetchTags) {
function getRefSpec(ref, commit) {
if (!ref && !commit) {
throw new Error('Args ref and commit cannot both be empty');
}
const upperRef = (ref || '').toUpperCase();
const result = [];
// When fetchTags is true, always include the tags refspec
if (fetchTags) {
result.push(exports.tagsRefSpec);
}
// SHA
if (commit) {
// refs/heads
if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length);
result.push(`+${commit}:refs/remotes/origin/${branch}`);
return [`+${commit}:refs/remotes/origin/${branch}`];
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length);
result.push(`+${commit}:refs/remotes/pull/${branch}`);
return [`+${commit}:refs/remotes/pull/${branch}`];
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`);
}
return [`+${commit}:${ref}`];
}
// Otherwise no destination ref
else {
result.push(commit);
return [commit];
}
}
// Unqualified ref, check for a matching branch or tag
else if (!upperRef.startsWith('REFS/')) {
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`);
if (!fetchTags) {
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`);
}
return [
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
`+refs/tags/${ref}*:refs/tags/${ref}*`
];
}
// refs/heads/
else if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length);
result.push(`+${ref}:refs/remotes/origin/${branch}`);
return [`+${ref}:refs/remotes/origin/${branch}`];
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length);
result.push(`+${ref}:refs/remotes/pull/${branch}`);
return [`+${ref}:refs/remotes/pull/${branch}`];
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`);
}
}
// Other refs
else {
result.push(`+${ref}:${ref}`);
return [`+${ref}:${ref}`];
}
return result;
}
/**
* Tests whether the initial fetch created the ref at the expected commit
@@ -2398,7 +2350,6 @@ function testRef(git, ref, commit) {
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
const tagName = ref.substring('refs/tags/'.length);
// Use ^{commit} to dereference annotated tags to their underlying commit
return ((yield git.tagExists(tagName)) &&
commit === (yield git.revParse(`${ref}^{commit}`)));
}
@@ -2450,7 +2401,7 @@ function checkCommitInfo(token, commitInfo, repositoryOwner, repositoryName, ref
return;
}
// Extract details from message
const match = commitInfo.match(/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/);
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/);
if (!match) {
core.debug('Unexpected message format');
return;

View File

@@ -13,12 +13,6 @@ import {IGitCommandManager} from './git-command-manager'
import {IGitSourceSettings} from './git-source-settings'
const IS_WINDOWS = process.platform === 'win32'
// Use case-insensitive gitdir matching on Windows to handle path casing mismatches
// between the runner's GITHUB_WORKSPACE and the actual filesystem casing.
// See: https://github.com/actions/checkout/issues/2345
const INCLUDE_IF_GITDIR = IS_WINDOWS
? 'includeIf.gitdir/i:'
: 'includeIf.gitdir:'
const SSH_COMMAND_KEY = 'core.sshCommand'
export interface IGitAuthHelper {
@@ -188,7 +182,7 @@ class GitAuthHelper {
// Configure host includeIf
await this.git.config(
`${INCLUDE_IF_GITDIR}${submoduleGitDir}.path`,
`includeIf.gitdir:${submoduleGitDir}.path`,
credentialsConfigPath,
false, // globalConfig?
false, // add?
@@ -210,7 +204,7 @@ class GitAuthHelper {
// Configure container includeIf
await this.git.config(
`${INCLUDE_IF_GITDIR}${containerSubmoduleGitDir}.path`,
`includeIf.gitdir:${containerSubmoduleGitDir}.path`,
containerCredentialsPath,
false, // globalConfig?
false, // add?
@@ -377,13 +371,9 @@ class GitAuthHelper {
gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
// Configure host includeIf
const hostIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}.path`
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${gitDir}/worktrees/*.path`
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
// Container git directory
const workingDirectory = this.git.getWorkingDirectory()
const githubWorkspace = process.env['GITHUB_WORKSPACE']
@@ -403,15 +393,8 @@ class GitAuthHelper {
)
// Configure container includeIf
const containerIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}.path`
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
await this.git.config(containerIncludeKey, containerCredentialsPath)
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `${INCLUDE_IF_GITDIR}${containerGitDir}/worktrees/*.path`
await this.git.config(
containerWorktreeIncludeKey,
containerCredentialsPath
)
}
}
@@ -560,7 +543,7 @@ class GitAuthHelper {
try {
// Get all includeIf.gitdir keys
const keys = await this.git.tryGetConfigKeys(
'^includeIf\\.gitdir(/i)?:',
'^includeIf\\.gitdir:',
false, // globalConfig?
configPath
)

View File

@@ -37,6 +37,7 @@ export interface IGitCommandManager {
options: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
}
): Promise<void>
@@ -279,13 +280,14 @@ class GitCommandManager {
options: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
}
): Promise<void> {
const args = ['-c', 'protocol.version=2', 'fetch']
// Always use --no-tags for explicit control over tag fetching
// Tags are fetched explicitly via refspec when needed
args.push('--no-tags')
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
args.push('--no-tags')
}
args.push('--prune', '--no-recurse-submodules')
if (options.showProgress) {
@@ -728,19 +730,7 @@ class GitCommandManager {
}
}
// Set the user agent
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
// Append orchestration ID if set
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
if (orchId) {
// Sanitize the orchestration ID to ensure it contains only valid characters
// Valid characters: 0-9, a-z, _, -, .
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
if (sanitizedId) {
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`
}
}
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
}

View File

@@ -159,6 +159,7 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
const fetchOptions: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
} = {}
@@ -181,35 +182,12 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
await git.fetch(refSpec, fetchOptions)
// Verify the ref now matches. For branches, the targeted fetch above brings
// in the specific commit. For tags (fetched by ref), this will fail if
// the tag was moved after the workflow was triggered.
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
throw new Error(
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
`The ref may have been updated after the workflow was triggered.`
)
}
}
} else {
fetchOptions.fetchDepth = settings.fetchDepth
const refSpec = refHelper.getRefSpec(
settings.ref,
settings.commit,
settings.fetchTags
)
fetchOptions.fetchTags = settings.fetchTags
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
await git.fetch(refSpec, fetchOptions)
// For tags, verify the ref still points to the expected commit.
// Tags are fetched by ref (not commit), so if a tag was moved after the
// workflow was triggered, we would silently check out the wrong commit.
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
throw new Error(
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
`The ref may have been updated after the workflow was triggered.`
)
}
}
core.endGroup()

View File

@@ -71,7 +71,7 @@ export async function getInputs(): Promise<IGitSourceSettings> {
}
}
// SHA?
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = result.ref
result.ref = ''
}

View File

@@ -76,75 +76,55 @@ export function getRefSpecForAllHistory(ref: string, commit: string): string[] {
return result
}
export function getRefSpec(
ref: string,
commit: string,
fetchTags?: boolean
): string[] {
export function getRefSpec(ref: string, commit: string): string[] {
if (!ref && !commit) {
throw new Error('Args ref and commit cannot both be empty')
}
const upperRef = (ref || '').toUpperCase()
const result: string[] = []
// When fetchTags is true, always include the tags refspec
if (fetchTags) {
result.push(tagsRefSpec)
}
// SHA
if (commit) {
// refs/heads
if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length)
result.push(`+${commit}:refs/remotes/origin/${branch}`)
return [`+${commit}:refs/remotes/origin/${branch}`]
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length)
result.push(`+${commit}:refs/remotes/pull/${branch}`)
return [`+${commit}:refs/remotes/pull/${branch}`]
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`)
}
return [`+${commit}:${ref}`]
}
// Otherwise no destination ref
else {
result.push(commit)
return [commit]
}
}
// Unqualified ref, check for a matching branch or tag
else if (!upperRef.startsWith('REFS/')) {
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`)
if (!fetchTags) {
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`)
}
return [
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
`+refs/tags/${ref}*:refs/tags/${ref}*`
]
}
// refs/heads/
else if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length)
result.push(`+${ref}:refs/remotes/origin/${branch}`)
return [`+${ref}:refs/remotes/origin/${branch}`]
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length)
result.push(`+${ref}:refs/remotes/pull/${branch}`)
return [`+${ref}:refs/remotes/pull/${branch}`]
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`)
}
}
// Other refs
else {
result.push(`+${ref}:${ref}`)
return [`+${ref}:${ref}`]
}
return result
}
/**
@@ -190,7 +170,6 @@ export async function testRef(
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
const tagName = ref.substring('refs/tags/'.length)
// Use ^{commit} to dereference annotated tags to their underlying commit
return (
(await git.tagExists(tagName)) &&
commit === (await git.revParse(`${ref}^{commit}`))
@@ -258,9 +237,7 @@ export async function checkCommitInfo(
}
// Extract details from message
const match = commitInfo.match(
/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/
)
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/)
if (!match) {
core.debug('Unexpected message format')
return