Compare commits

..

10 Commits

Author SHA1 Message Date
Aiqiao Yan b8447332b0 block checking out fork pr for some events 2026-06-12 19:12:01 +00:00
Yashwanth Anantharaju df4cb1c069 Update changelog for v6.0.3 (#2446) 2026-06-02 10:31:30 -04:00
Yashwanth Anantharaju 1cce3390c2 Fix checkout init for SHA-256 repositories (#2439)
* Fix checkout init for SHA-256 repositories

* Remove unused object format result field
2026-06-01 11:35:58 -04:00
Yashwanth Anantharaju 900f2210b1 fix: expand merge commit SHA regex and add SHA-256 test cases (#2414)
* fix: expand merge commit SHA regex and add SHA-256 test cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add checkCommitInfo SHA coverage

Add checkCommitInfo tests for SHA-1 and SHA-256 merge messages and reject invalid 50-character hex merge heads.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: fix Prettier formatting in test and source files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 13:30:55 -04:00
eric sciple 0c366fd6a8 Update changelog (#2357) 2026-01-09 14:09:42 -06:00
eric sciple de0fac2e45 Fix tag handling: preserve annotations and explicit fetch-tags (#2356)
This PR fixes several issues with tag handling in the checkout action:

1. fetch-tags: true now works (fixes #1471)
   - Tags refspec is now included in getRefSpec() when fetchTags=true
   - Previously tags were only fetched during a separate fetch that was
     overwritten by the main fetch

2. Tag checkout preserves annotations (fixes #290)
   - Tags are fetched via refspec (+refs/tags/*:refs/tags/*) instead of
     --tags flag
   - This fetches the actual tag objects, preserving annotations

3. Tag checkout with fetch-tags: true no longer fails (fixes #1467)
   - When checking out a tag with fetchTags=true, only the wildcard
     refspec is used (specific tag refspec is redundant)

Changes:
- src/ref-helper.ts: getRefSpec() now accepts fetchTags parameter and
  prepends tags refspec when true
- src/git-command-manager.ts: fetch() simplified to always use --no-tags,
  tags are fetched explicitly via refspec
- src/git-source-provider.ts: passes fetchTags to getRefSpec()
- Added E2E test for fetch-tags option

Related #1471, #1467, #290
2026-01-09 13:42:23 -06:00
Copilot 064fe7f331 Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID is set (#2355)
* Initial plan

* Add orchestration ID support to git user-agent

Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improve tests to verify user-agent content and handle empty sanitized IDs

Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>

* Simplify orchestration ID validation to accept any non-empty sanitized value

Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>

* Remove test for orchestration ID with only invalid characters

Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TingluoHuang <1750815+TingluoHuang@users.noreply.github.com>
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 15:07:38 -05:00
eric sciple 8e8c483db8 Clarify v6 README (#2328) 2025-12-01 20:08:49 -06:00
eric sciple 033fa0dc0b Add worktree support for persist-credentials includeIf (#2327) 2025-12-01 19:53:23 -06:00
eric sciple c2d88d3ecc Update all references from v5 and v4 to v6 (#2314)
- Updated README.md examples to reference @v6
- Updated all workflow files to use actions/checkout@v6
2025-11-23 19:32:55 -06:00
28 changed files with 1421 additions and 149 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.6
- uses: actions/checkout@v6
- name: Set Node.js 24.x
uses: actions/setup-node@v4
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
name: Check licenses
steps:
- uses: actions/checkout@v4.1.6
- uses: actions/checkout@v6
- run: npm ci
- run: npm run licensed-check
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checking out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Publish
id: publish
uses: actions/publish-immutable-action@0.0.3
+35 -8
View File
@@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24.x
- uses: actions/checkout@v4.1.6
- uses: actions/checkout@v6
- run: npm ci
- run: npm run build
- run: npm run format-check
@@ -37,7 +37,7 @@ jobs:
steps:
# Clone this repo
- name: Checkout
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
# Basic checkout
- name: Checkout basic
@@ -87,6 +87,17 @@ 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: ./
@@ -165,6 +176,22 @@ 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'
@@ -202,7 +229,7 @@ jobs:
steps:
# Clone this repo
- name: Checkout
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
# Basic checkout using git
- name: Checkout basic
@@ -234,7 +261,7 @@ jobs:
steps:
# Clone this repo
- name: Checkout
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
# Basic checkout using git
- name: Checkout basic
@@ -264,7 +291,7 @@ jobs:
steps:
# Clone this repo
- name: Checkout
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
with:
path: localClone
@@ -291,8 +318,8 @@ jobs:
git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main
# needed to make checkout post cleanup succeed
- name: Fix Checkout v4
uses: actions/checkout@v4.1.6
- name: Fix Checkout v6
uses: actions/checkout@v6
with:
path: localClone
@@ -301,7 +328,7 @@ jobs:
steps:
# Clone this repo
- name: Checkout
uses: actions/checkout@v4.1.6
uses: actions/checkout@v6
with:
path: actions-checkout
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
# Note this update workflow can also be used as a rollback tool.
# For that reason, it's best to pin `actions/checkout` to a known, stable version
# (typically, about two releases back).
- uses: actions/checkout@v4.1.6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Git config
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
# Use `docker/login-action` to log in to GHCR.io.
# Once published, the packages are scoped to the account defined here.
+15 -5
View File
@@ -1,19 +1,29 @@
# Changelog
## V6.0.0
## v6.0.3
* Fix checkout init for SHA-256 repositories by @yaananth in https://github.com/actions/checkout/pull/2439
* fix: expand merge commit SHA regex and add SHA-256 test cases by @yaananth in https://github.com/actions/checkout/pull/2414
## 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
* 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
+26 -19
View File
@@ -4,8 +4,9 @@
## What's new
- 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.
- 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
# Checkout v5
@@ -51,7 +52,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
<!-- start usage -->
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
# Repository name with owner. For example, actions/checkout
# Default: ${{ github.repository }}
@@ -159,6 +160,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# running from unless specified. Example URLs are https://github.com or
# https://my-ghes-server.example.com
github-server-url: ''
# Required to check out fork pull request code from a workflow triggered by
# `pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link) for
# the risks. Set to `true` only after reviewing the risks.
# Default: false
allow-unsafe-pr-checkout: ''
```
<!-- end usage -->
@@ -190,7 +197,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only the root files
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
sparse-checkout: .
```
@@ -198,7 +205,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only the root files and `.github` and `src` folder
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
sparse-checkout: |
.github
@@ -208,7 +215,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only a single file
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
sparse-checkout: |
README.md
@@ -218,7 +225,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch all history for all tags and branches
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
```
@@ -226,7 +233,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout a different branch
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
ref: my-branch
```
@@ -234,7 +241,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout HEAD^
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 2
- run: git checkout HEAD^
@@ -244,12 +251,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
path: main
- name: Checkout tools repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: my-org/my-tools
path: my-tools
@@ -260,10 +267,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Checkout tools repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: my-org/my-tools
path: my-tools
@@ -274,12 +281,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
path: main
- name: Checkout private tools
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: my-org/my-private-tools
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
@@ -292,7 +299,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout pull request HEAD commit instead of merge commit
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
```
@@ -308,7 +315,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
```
## Push a commit using the built-in token
@@ -319,7 +326,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: |
date > generated.txt
# Note: the following account information will not work on GHES
@@ -341,7 +348,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
- run: |
+2 -1
View File
@@ -1173,7 +1173,8 @@ async function setup(testName: string): Promise<void> {
sshUser: '',
workflowOrganizationId: 123456,
setSafeDirectory: true,
githubServerUrl: githubServerUrl
githubServerUrl: githubServerUrl,
allowUnsafePrCheckout: false
}
}
+223 -52
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 and fetchTags is true', async () => {
it('should call execGit with the correct arguments when fetchDepth is 0', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
const lfs = false
@@ -122,45 +122,7 @@ describe('Test fetchDepth and fetchTags options', () => {
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
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
fetchDepth: 0
}
await git.fetch(refSpec, options)
@@ -183,7 +145,45 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is false', async () => {
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 () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -197,8 +197,7 @@ describe('Test fetchDepth and fetchTags options', () => {
const refSpec = ['refspec1', 'refspec2']
const options = {
filter: 'filterValue',
fetchDepth: 1,
fetchTags: false
fetchDepth: 1
}
await git.fetch(refSpec, options)
@@ -222,7 +221,7 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is true', async () => {
it('should call execGit with the correct arguments when fetchDepth is 1 and refSpec includes tags', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -233,11 +232,10 @@ describe('Test fetchDepth and fetchTags options', () => {
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2']
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
const options = {
filter: 'filterValue',
fetchDepth: 1,
fetchTags: true
fetchDepth: 1
}
await git.fetch(refSpec, options)
@@ -248,13 +246,15 @@ describe('Test fetchDepth and fetchTags options', () => {
'-c',
'protocol.version=2',
'fetch',
'--no-tags',
'--prune',
'--no-recurse-submodules',
'--filter=filterValue',
'--depth=1',
'origin',
'refspec1',
'refspec2'
'refspec2',
'+refs/tags/*:refs/tags/*'
],
expect.any(Object)
)
@@ -338,7 +338,7 @@ describe('Test fetchDepth and fetchTags options', () => {
)
})
it('should call execGit with the correct arguments when fetchTags is true and showProgress is true', async () => {
it('should call execGit with the correct arguments when showProgress is true and refSpec includes tags', async () => {
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test'
@@ -349,10 +349,9 @@ describe('Test fetchDepth and fetchTags options', () => {
lfs,
doSparseCheckout
)
const refSpec = ['refspec1', 'refspec2']
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
const options = {
filter: 'filterValue',
fetchTags: true,
showProgress: true
}
@@ -364,15 +363,187 @@ describe('Test fetchDepth and fetchTags options', () => {
'-c',
'protocol.version=2',
'fetch',
'--no-tags',
'--prune',
'--no-recurse-submodules',
'--progress',
'--filter=filterValue',
'origin',
'refspec1',
'refspec2'
'refspec2',
'+refs/tags/*:refs/tags/*'
],
expect.any(Object)
)
})
})
describe('repository initialization object format', () => {
beforeEach(async () => {
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
})
afterEach(() => {
jest.restoreAllMocks()
})
it('initializes SHA-256 repositories with the matching object format', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
await git.init('sha256')
expect(mockExec).toHaveBeenCalledWith(
expect.any(String),
['init', '--object-format=sha256', 'test'],
expect.any(Object)
)
})
it('initializes SHA-1 repositories with existing default arguments', async () => {
mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
return 0
})
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
git = await commandManager.createCommandManager('test', false, false)
await git.init('sha1')
expect(mockExec).toHaveBeenCalledWith(
expect.any(String),
['init', 'test'],
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)'
)
})
})
+98
View File
@@ -0,0 +1,98 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as githubApiHelper from '../lib/github-api-helper'
describe('github-api-helper object format', () => {
let getOctokitSpy: jest.SpyInstance
let debugSpy: jest.SpyInstance
let request: jest.Mock
function mockHashAlgorithmApi(hashAlgorithm: string): void {
request = jest.fn(async () => ({
data: {
hash_algorithm: hashAlgorithm
}
}))
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
request
} as any)
}
beforeEach(() => {
debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn())
})
afterEach(() => {
jest.restoreAllMocks()
})
it('detects SHA-256 from the repository hash algorithm endpoint', async () => {
mockHashAlgorithmApi('sha256')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: 'sha256', succeeded: true})
expect(getOctokitSpy).toHaveBeenCalledWith(
'token',
expect.objectContaining({baseUrl: 'https://api.github.com'})
)
expect(request).toHaveBeenCalledWith(
'GET /repos/{owner}/{repo}/hash-algorithm',
{owner: 'owner', repo: 'repo'}
)
})
it('detects SHA-1 from the repository hash algorithm endpoint', async () => {
mockHashAlgorithmApi('sha1')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: 'sha1', succeeded: true})
})
it('detects object format from an existing commit without API calls', async () => {
const commitSha =
'9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92'
getOctokitSpy = jest.spyOn(github, 'getOctokit')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat(
'token',
'owner',
'repo',
undefined,
commitSha
)
).resolves.toEqual({format: 'sha256', succeeded: true})
expect(getOctokitSpy).not.toHaveBeenCalled()
})
it('returns unsuccessful when the hash algorithm endpoint value is not recognized', async () => {
mockHashAlgorithmApi('unknown')
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: '', succeeded: false})
expect(debugSpy).toHaveBeenCalledWith(
'Unable to determine repository object format from hash-algorithm endpoint'
)
})
it('returns unsuccessful when the hash algorithm API lookup fails', async () => {
request = jest.fn(async () => {
throw new Error('not found')
})
jest.spyOn(github, 'getOctokit').mockReturnValue({
request
} as any)
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: '', succeeded: false})
expect(debugSpy).toHaveBeenCalledWith(
'Unable to determine repository object format from hash-algorithm endpoint: not found'
)
})
})
+11
View File
@@ -91,6 +91,7 @@ describe('input-helper tests', () => {
expect(settings.repositoryOwner).toBe('some-owner')
expect(settings.repositoryPath).toBe(gitHubWorkspace)
expect(settings.setSafeDirectory).toBe(true)
expect(settings.allowUnsafePrCheckout).toBe(false)
})
it('qualifies ref', async () => {
@@ -133,6 +134,16 @@ 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()
+189 -1
View File
@@ -1,8 +1,12 @@
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', () => {
@@ -37,6 +41,12 @@ 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,
@@ -152,7 +162,22 @@ 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(`+${commit}:refs/tags/my-tag`)
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`)
})
it('getRefSpec sha only', async () => {
@@ -168,6 +193,14 @@ 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)
@@ -187,4 +220,159 @@ 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')
})
})
})
+254
View File
@@ -0,0 +1,254 @@
import * as github from '@actions/github'
import {assertSafePrCheckout} from '../lib/unsafe-pr-checkout-helper'
// Shallow clone original @actions/github context
const originalContext = {...github.context}
const originalEventName = github.context.eventName
const originalPayload = github.context.payload
const BASE_REPO_ID = 100
const FORK_REPO_ID = 200
const PR_HEAD_SHA = '1111111111111111111111111111111111111111'
const PR_MERGE_SHA = '2222222222222222222222222222222222222222'
const SAFE_BASE_SHA = '3333333333333333333333333333333333333333'
const WORKFLOW_RUN_HEAD_COMMIT_SHA = '4444444444444444444444444444444444444444'
const BASE_QUALIFIED_REPO = 'some-owner/some-repo'
function setContext(eventName: string, payload: object): void {
;(github.context as {eventName: string}).eventName = eventName
;(github.context as {payload: object}).payload = payload
}
function forkPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: FORK_REPO_ID}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function sameRepoPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: BASE_REPO_ID}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function forkWorkflowRunPayload(): object {
return {
repository: {id: BASE_REPO_ID},
workflow_run: {
event: 'pull_request',
head_commit: {id: WORKFLOW_RUN_HEAD_COMMIT_SHA},
head_repository: {id: FORK_REPO_ID}
}
}
}
describe('unsafe-pr-checkout-helper', () => {
beforeAll(() => {
jest.spyOn(github.context, 'repo', 'get').mockReturnValue({
owner: 'some-owner',
repo: 'some-repo'
})
})
afterEach(() => {
;(github.context as {eventName: string}).eventName = originalEventName
;(github.context as {payload: object}).payload = originalPayload
})
afterAll(() => {
;(github.context as {eventName: string}).eventName =
originalContext.eventName
;(github.context as {payload: object}).payload = originalContext.payload
jest.restoreAllMocks()
})
it('allows pull_request events untouched', () => {
setContext('pull_request', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'attacker/fork',
ref: 'refs/pull/1/merge',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows pull_request_target default checkout (base branch)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: SAFE_BASE_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows same-repo pull_request_target checkout of PR head', () => {
setContext('pull_request_target', sameRepoPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target fork PR head SHA checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/Refusing to check out fork pull request code/)
})
it('refuses pull_request_target fork PR merge_commit_sha checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_MERGE_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/allow-unsafe-pr-checkout/)
})
it('refuses pull_request_target fork PR ref pattern (head)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/head',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target fork PR ref pattern (merge)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target when repository points at the fork', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'attacker/fork',
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target ignoring repository case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'SOME-OWNER/SOME-REPO',
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target ignoring commit SHA case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA.toUpperCase(),
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target fork PR checkout when opted in', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: true
})
).not.toThrow()
})
it('refuses workflow_run fork PR head_commit.id checkout', () => {
setContext('workflow_run', forkWorkflowRunPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses workflow_run with pull_request_target underlying event', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {event: string}
}
payload.workflow_run.event = 'pull_request_target'
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows workflow_run same-repo PR (head_repository.id matches base)', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {head_repository: {id: number}}
}
payload.workflow_run.head_repository.id = BASE_REPO_ID
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
})
+9
View File
@@ -0,0 +1,9 @@
#!/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"
+51
View File
@@ -0,0 +1,51 @@
#!/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!"
+6
View File
@@ -98,6 +98,12 @@ inputs:
github-server-url:
description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com
required: false
allow-unsafe-pr-checkout:
description: >
Required to check out fork pull request code from a workflow triggered by
`pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link)
for the risks. Set to `true` only after reviewing the risks.
default: false
outputs:
ref:
description: 'The branch, tag or SHA that was checked out'
+223 -26
View File
@@ -412,6 +412,9 @@ class GitAuthHelper {
// Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
yield this.git.config(hostIncludeKey, credentialsConfigPath);
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`;
yield this.git.config(hostWorktreeIncludeKey, credentialsConfigPath);
// Container git directory
const workingDirectory = this.git.getWorkingDirectory();
const githubWorkspace = process.env['GITHUB_WORKSPACE'];
@@ -424,6 +427,9 @@ class GitAuthHelper {
// Configure container includeIf
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`;
yield this.git.config(containerIncludeKey, containerCredentialsPath);
// Configure container includeIf for worktrees
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`;
yield this.git.config(containerWorktreeIncludeKey, containerCredentialsPath);
}
});
}
@@ -647,7 +653,6 @@ 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);
@@ -825,9 +830,9 @@ class GitCommandManager {
fetch(refSpec, options) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['-c', 'protocol.version=2', 'fetch'];
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
args.push('--no-tags');
}
// Always use --no-tags for explicit control over tag fetching
// Tags are fetched explicitly via refspec when needed
args.push('--no-tags');
args.push('--prune', '--no-recurse-submodules');
if (options.showProgress) {
args.push('--progress');
@@ -891,9 +896,14 @@ class GitCommandManager {
getWorkingDirectory() {
return this.workingDirectory;
}
init() {
init(objectFormat) {
return __awaiter(this, void 0, void 0, function* () {
yield this.execGit(['init', this.workingDirectory]);
const args = ['init'];
if (objectFormat === 'sha256') {
args.push('--object-format=sha256');
}
args.push(this.workingDirectory);
yield this.execGit(args);
});
}
isDetached() {
@@ -1200,7 +1210,17 @@ class GitCommandManager {
}
}
// Set the user agent
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`;
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}`;
}
}
core.debug(`Set git useragent to: ${gitHttpUserAgent}`);
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent;
});
@@ -1471,8 +1491,17 @@ function getSource(settings) {
stateHelper.setRepositoryPath(settings.repositoryPath);
// Initialize the repository
if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) {
core.startGroup('Determining repository object format');
const objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl, settings.commit);
const objectFormat = objectFormatResult.succeeded
? objectFormatResult.format
: '';
if (objectFormat === 'sha256') {
core.info('Detected SHA-256 repository object format');
}
core.endGroup();
core.startGroup('Initializing the repository');
yield git.init();
yield git.init(objectFormat);
yield git.remoteAdd('origin', repositoryUrl);
core.endGroup();
}
@@ -1523,13 +1552,26 @@ 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;
fetchOptions.fetchTags = settings.fetchTags;
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit, settings.fetchTags);
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
@@ -1782,6 +1824,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.downloadRepository = downloadRepository;
exports.getDefaultBranch = getDefaultBranch;
exports.tryGetRepositoryObjectFormat = tryGetRepositoryObjectFormat;
const assert = __importStar(__nccwpck_require__(9491));
const core = __importStar(__nccwpck_require__(2186));
const fs = __importStar(__nccwpck_require__(7147));
@@ -1883,6 +1926,40 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) {
}));
});
}
function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, commit) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const commitFormat = getObjectFormat(commit);
if (commitFormat) {
return { format: commitFormat, succeeded: true };
}
try {
const octokit = github.getOctokit(authToken, {
baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl)
});
const response = yield octokit.request('GET /repos/{owner}/{repo}/hash-algorithm', { owner, repo });
const hashAlgorithm = response.data.hash_algorithm;
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
return { format: hashAlgorithm, succeeded: true };
}
core.debug('Unable to determine repository object format from hash-algorithm endpoint');
return { format: '', succeeded: false };
}
catch (err) {
core.debug(`Unable to determine repository object format from hash-algorithm endpoint: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
return { format: '', succeeded: false };
}
});
}
function getObjectFormat(sha) {
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
return 'sha256';
}
if (/^[0-9a-fA-F]{40}$/.test(sha || '')) {
return 'sha1';
}
return '';
}
function downloadArchive(authToken, owner, repo, ref, commit, baseUrl) {
return __awaiter(this, void 0, void 0, function* () {
const octokit = github.getOctokit(authToken, {
@@ -1946,6 +2023,7 @@ const core = __importStar(__nccwpck_require__(2186));
const fsHelper = __importStar(__nccwpck_require__(7219));
const github = __importStar(__nccwpck_require__(5438));
const path = __importStar(__nccwpck_require__(1017));
const unsafePrCheckoutHelper = __importStar(__nccwpck_require__(843));
const workflowContextHelper = __importStar(__nccwpck_require__(9568));
function getInputs() {
return __awaiter(this, void 0, void 0, function* () {
@@ -1993,7 +2071,7 @@ function getInputs() {
}
}
// SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref;
result.ref = '';
}
@@ -2067,6 +2145,17 @@ function getInputs() {
// Determine the GitHub URL that the repository is being hosted from
result.githubServerUrl = core.getInput('github-server-url');
core.debug(`GitHub Host URL = ${result.githubServerUrl}`);
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE';
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`);
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
});
return result;
});
}
@@ -2207,6 +2296,7 @@ exports.getRefSpecForAllHistory = getRefSpecForAllHistory;
exports.getRefSpec = getRefSpec;
exports.testRef = testRef;
exports.checkCommitInfo = checkCommitInfo;
exports.fromPayload = fromPayload;
const core = __importStar(__nccwpck_require__(2186));
const github = __importStar(__nccwpck_require__(5438));
const url_helper_1 = __nccwpck_require__(9437);
@@ -2268,53 +2358,67 @@ function getRefSpecForAllHistory(ref, commit) {
}
return result;
}
function getRefSpec(ref, commit) {
function getRefSpec(ref, commit, fetchTags) {
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);
return [`+${commit}:refs/remotes/origin/${branch}`];
result.push(`+${commit}:refs/remotes/origin/${branch}`);
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length);
return [`+${commit}:refs/remotes/pull/${branch}`];
result.push(`+${commit}:refs/remotes/pull/${branch}`);
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
return [`+${commit}:${ref}`];
if (!fetchTags) {
result.push(`+${ref}:${ref}`);
}
}
// Otherwise no destination ref
else {
return [commit];
result.push(commit);
}
}
// Unqualified ref, check for a matching branch or tag
else if (!upperRef.startsWith('REFS/')) {
return [
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
`+refs/tags/${ref}*:refs/tags/${ref}*`
];
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`);
if (!fetchTags) {
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`);
}
}
// refs/heads/
else if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length);
return [`+${ref}:refs/remotes/origin/${branch}`];
result.push(`+${ref}:refs/remotes/origin/${branch}`);
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length);
return [`+${ref}:refs/remotes/pull/${branch}`];
result.push(`+${ref}:refs/remotes/pull/${branch}`);
}
// refs/tags/
else {
return [`+${ref}:${ref}`];
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`);
}
}
// Other refs
else {
result.push(`+${ref}:${ref}`);
}
return result;
}
/**
* Tests whether the initial fetch created the ref at the expected commit
@@ -2350,7 +2454,9 @@ function testRef(git, ref, commit) {
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
const tagName = ref.substring('refs/tags/'.length);
return ((yield git.tagExists(tagName)) && commit === (yield git.revParse(ref)));
// Use ^{commit} to dereference annotated tags to their underlying commit
return ((yield git.tagExists(tagName)) &&
commit === (yield git.revParse(`${ref}^{commit}`)));
}
// Unexpected
else {
@@ -2400,7 +2506,7 @@ function checkCommitInfo(token, commitInfo, repositoryOwner, repositoryName, ref
return;
}
// Extract details from message
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/);
const match = commitInfo.match(/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/);
if (!match) {
core.debug('Unexpected message format');
return;
@@ -2639,6 +2745,97 @@ if (!exports.IsPost) {
}
/***/ }),
/***/ 843:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.assertSafePrCheckout = assertSafePrCheckout;
const github = __importStar(__nccwpck_require__(5438));
const ref_helper_1 = __nccwpck_require__(8601);
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/;
function assertSafePrCheckout(input) {
if (input.allowUnsafePrCheckout) {
return;
}
const eventName = github.context.eventName;
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return;
}
const baseRepoId = (0, ref_helper_1.fromPayload)('repository.id');
if (typeof baseRepoId !== 'number') {
return;
}
let prHeadRepoId;
const prShas = [];
if (eventName === 'pull_request_target') {
prHeadRepoId = (0, ref_helper_1.fromPayload)('pull_request.head.repo.id');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.head.sha'));
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.merge_commit_sha'));
}
else {
const wrEvent = (0, ref_helper_1.fromPayload)('workflow_run.event');
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return;
}
prHeadRepoId = (0, ref_helper_1.fromPayload)('workflow_run.head_repository.id');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_commit.id'));
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return;
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`;
const repositoryDiffersFromBase = input.qualifiedRepository.toLowerCase() !==
baseQualifiedRepository.toLowerCase();
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref);
const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase());
if (!repositoryDiffersFromBase &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha) {
return;
}
throw new Error(`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching fork's code in that trusted context is a ` +
`"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, set ` +
`'allow-unsafe-pr-checkout: true' on the actions/checkout step.`);
}
function pushIfSha(target, value) {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase());
}
}
/***/ }),
/***/ 9437:
+11
View File
@@ -374,6 +374,10 @@ class GitAuthHelper {
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
// Configure host includeIf for worktrees
const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
// Container git directory
const workingDirectory = this.git.getWorkingDirectory()
const githubWorkspace = process.env['GITHUB_WORKSPACE']
@@ -395,6 +399,13 @@ class GitAuthHelper {
// 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
)
}
}
+25 -9
View File
@@ -37,14 +37,13 @@ export interface IGitCommandManager {
options: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
init(objectFormat?: string): Promise<void>
isDetached(): Promise<boolean>
lfsFetch(ref: string): Promise<void>
lfsInstall(): Promise<void>
@@ -280,14 +279,13 @@ class GitCommandManager {
options: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
}
): Promise<void> {
const args = ['-c', 'protocol.version=2', 'fetch']
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
args.push('--no-tags')
}
// Always use --no-tags for explicit control over tag fetching
// Tags are fetched explicitly via refspec when needed
args.push('--no-tags')
args.push('--prune', '--no-recurse-submodules')
if (options.showProgress) {
@@ -366,8 +364,14 @@ class GitCommandManager {
return this.workingDirectory
}
async init(): Promise<void> {
await this.execGit(['init', this.workingDirectory])
async init(objectFormat?: string): Promise<void> {
const args = ['init']
if (objectFormat === 'sha256') {
args.push('--object-format=sha256')
}
args.push(this.workingDirectory)
await this.execGit(args)
}
async isDetached(): Promise<boolean> {
@@ -730,7 +734,19 @@ class GitCommandManager {
}
}
// Set the user agent
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
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}`
}
}
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
}
+43 -4
View File
@@ -109,8 +109,25 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
if (
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
) {
core.startGroup('Determining repository object format')
const objectFormatResult =
await githubApiHelper.tryGetRepositoryObjectFormat(
settings.authToken,
settings.repositoryOwner,
settings.repositoryName,
settings.githubServerUrl,
settings.commit
)
const objectFormat = objectFormatResult.succeeded
? objectFormatResult.format
: ''
if (objectFormat === 'sha256') {
core.info('Detected SHA-256 repository object format')
}
core.endGroup()
core.startGroup('Initializing the repository')
await git.init()
await git.init(objectFormat)
await git.remoteAdd('origin', repositoryUrl)
core.endGroup()
}
@@ -159,7 +176,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
const fetchOptions: {
filter?: string
fetchDepth?: number
fetchTags?: boolean
showProgress?: boolean
} = {}
@@ -182,12 +198,35 @@ 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
fetchOptions.fetchTags = settings.fetchTags
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
const refSpec = refHelper.getRefSpec(
settings.ref,
settings.commit,
settings.fetchTags
)
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()
+6
View File
@@ -118,4 +118,10 @@ export interface IGitSourceSettings {
* User override on the GitHub Server/Host URL that hosts the repository to be cloned
*/
githubServerUrl: string | undefined
/**
* Opt-in to allow checking out fork pull request code from a workflow
* triggered by pull_request_target or workflow_run.
*/
allowUnsafePrCheckout: boolean
}
+52
View File
@@ -11,6 +11,11 @@ import {getServerApiUrl} from './url-helper'
const IS_WINDOWS = process.platform === 'win32'
export interface RepositoryObjectFormatResult {
format: string
succeeded: boolean
}
export async function downloadRepository(
authToken: string,
owner: string,
@@ -122,6 +127,53 @@ export async function getDefaultBranch(
})
}
export async function tryGetRepositoryObjectFormat(
authToken: string,
owner: string,
repo: string,
baseUrl?: string,
commit?: string
): Promise<RepositoryObjectFormatResult> {
const commitFormat = getObjectFormat(commit)
if (commitFormat) {
return {format: commitFormat, succeeded: true}
}
try {
const octokit = github.getOctokit(authToken, {
baseUrl: getServerApiUrl(baseUrl)
})
const response = await octokit.request(
'GET /repos/{owner}/{repo}/hash-algorithm',
{owner, repo}
)
const hashAlgorithm = response.data.hash_algorithm
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
return {format: hashAlgorithm, succeeded: true}
}
core.debug(
'Unable to determine repository object format from hash-algorithm endpoint'
)
return {format: '', succeeded: false}
} catch (err) {
core.debug(
`Unable to determine repository object format from hash-algorithm endpoint: ${(err as any)?.message ?? err}`
)
return {format: '', succeeded: false}
}
}
function getObjectFormat(sha?: string): string {
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
return 'sha256'
}
if (/^[0-9a-fA-F]{40}$/.test(sha || '')) {
return 'sha1'
}
return ''
}
async function downloadArchive(
authToken: string,
owner: string,
+15 -1
View File
@@ -2,6 +2,7 @@ import * as core from '@actions/core'
import * as fsHelper from './fs-helper'
import * as github from '@actions/github'
import * as path from 'path'
import * as unsafePrCheckoutHelper from './unsafe-pr-checkout-helper'
import * as workflowContextHelper from './workflow-context-helper'
import {IGitSourceSettings} from './git-source-settings'
@@ -71,7 +72,7 @@ export async function getInputs(): Promise<IGitSourceSettings> {
}
}
// SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref
result.ref = ''
}
@@ -161,5 +162,18 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.githubServerUrl = core.getInput('github-server-url')
core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE'
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`)
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
})
return result
}
+1 -1
View File
@@ -120,7 +120,7 @@ function updateUsage(
}
updateUsage(
'actions/checkout@v5',
'actions/checkout@v6',
path.join(__dirname, '..', '..', 'action.yml'),
path.join(__dirname, '..', '..', 'README.md')
)
+40 -16
View File
@@ -76,55 +76,75 @@ export function getRefSpecForAllHistory(ref: string, commit: string): string[] {
return result
}
export function getRefSpec(ref: string, commit: string): string[] {
export function getRefSpec(
ref: string,
commit: string,
fetchTags?: boolean
): 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)
return [`+${commit}:refs/remotes/origin/${branch}`]
result.push(`+${commit}:refs/remotes/origin/${branch}`)
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length)
return [`+${commit}:refs/remotes/pull/${branch}`]
result.push(`+${commit}:refs/remotes/pull/${branch}`)
}
// refs/tags/
else if (upperRef.startsWith('REFS/TAGS/')) {
return [`+${commit}:${ref}`]
if (!fetchTags) {
result.push(`+${ref}:${ref}`)
}
}
// Otherwise no destination ref
else {
return [commit]
result.push(commit)
}
}
// Unqualified ref, check for a matching branch or tag
else if (!upperRef.startsWith('REFS/')) {
return [
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
`+refs/tags/${ref}*:refs/tags/${ref}*`
]
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`)
if (!fetchTags) {
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`)
}
}
// refs/heads/
else if (upperRef.startsWith('REFS/HEADS/')) {
const branch = ref.substring('refs/heads/'.length)
return [`+${ref}:refs/remotes/origin/${branch}`]
result.push(`+${ref}:refs/remotes/origin/${branch}`)
}
// refs/pull/
else if (upperRef.startsWith('REFS/PULL/')) {
const branch = ref.substring('refs/pull/'.length)
return [`+${ref}:refs/remotes/pull/${branch}`]
result.push(`+${ref}:refs/remotes/pull/${branch}`)
}
// refs/tags/
else {
return [`+${ref}:${ref}`]
else if (upperRef.startsWith('REFS/TAGS/')) {
if (!fetchTags) {
result.push(`+${ref}:${ref}`)
}
}
// Other refs
else {
result.push(`+${ref}:${ref}`)
}
return result
}
/**
@@ -170,8 +190,10 @@ 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))
(await git.tagExists(tagName)) &&
commit === (await git.revParse(`${ref}^{commit}`))
)
}
// Unexpected
@@ -236,7 +258,9 @@ export async function checkCommitInfo(
}
// Extract details from message
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/)
const match = commitInfo.match(
/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/
)
if (!match) {
core.debug('Unexpected message format')
return
@@ -268,7 +292,7 @@ export async function checkCommitInfo(
}
}
function fromPayload(path: string): any {
export function fromPayload(path: string): any {
return select(github.context.payload, path)
}
+80
View File
@@ -0,0 +1,80 @@
import * as github from '@actions/github'
import {fromPayload} from './ref-helper'
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/
export interface IUnsafePrCheckoutInput {
qualifiedRepository: string
ref: string
commit: string
allowUnsafePrCheckout: boolean
}
export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void {
if (input.allowUnsafePrCheckout) {
return
}
const eventName = github.context.eventName
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return
}
const baseRepoId = fromPayload('repository.id')
if (typeof baseRepoId !== 'number') {
return
}
let prHeadRepoId: unknown
const prShas: string[] = []
if (eventName === 'pull_request_target') {
prHeadRepoId = fromPayload('pull_request.head.repo.id')
pushIfSha(prShas, fromPayload('pull_request.head.sha'))
pushIfSha(prShas, fromPayload('pull_request.merge_commit_sha'))
} else {
const wrEvent = fromPayload('workflow_run.event')
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return
}
prHeadRepoId = fromPayload('workflow_run.head_repository.id')
pushIfSha(prShas, fromPayload('workflow_run.head_commit.id'))
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`
const repositoryDiffersFromBase =
input.qualifiedRepository.toLowerCase() !==
baseQualifiedRepository.toLowerCase()
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref)
const commitMatchesPrHeadSha =
!!input.commit && prShas.includes(input.commit.toLowerCase())
if (
!repositoryDiffersFromBase &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha
) {
return
}
throw new Error(
`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching fork's code in that trusted context is a ` +
`"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, set ` +
`'allow-unsafe-pr-checkout: true' on the actions/checkout step.`
)
}
function pushIfSha(target: string[], value: unknown): void {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase())
}
}