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

Compare commits

..

2 Commits

Author SHA1 Message Date
Michael Wyraz
ed69f3bbdd feat: override fetch-depth to 0 when reference-cache is active
When reference-cache is enabled, shallow fetches (fetch-depth > 0) are
counterproductive because objects are served from the local cache.
Shallow negotiation only adds network latency without saving bandwidth.

If fetch-depth was not explicitly set by the user, it is automatically
overridden to 0. If explicitly set, a warning is emitted explaining
the performance impact.

Signed-off-by: Michael Wyraz <mw@brick4u.de>
2026-03-05 15:58:21 +01:00
Michael Wyraz
9ddd3f4b35 feat: implement reference-cache for faster checkouts
- Add `reference-cache` input to action.yml
- Introduce `GitCacheHelper` for bare clone cache management
- Prevent race conditions with `proper-lockfile` and atomic directory renames
- Support iterative submodule caching and robust relative URL resolution
- Append to `info/alternates` preserving existing alternate references
- Add fallback to standard clone on submodule cache failure
- Add unit tests for `GitCacheHelper`

Signed-off-by: Michael Wyraz <mw@brick4u.de>
2026-03-05 15:33:54 +01:00
69 changed files with 28526 additions and 30152 deletions

View File

@@ -17,4 +17,4 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Publish - name: Publish
id: publish id: publish
uses: actions/publish-immutable-action@v0.0.4 uses: actions/publish-immutable-action@0.0.3

View File

@@ -11,5 +11,4 @@ allowed:
- unlicense - unlicense
reviewed: reviewed:
npm: npm:
- "@actions/http-client" # MIT

View File

@@ -1,6 +1,6 @@
--- ---
name: "@actions/core" name: "@actions/core"
version: 3.0.1 version: 1.10.1
type: npm type: npm
summary: Actions core lib summary: Actions core lib
homepage: https://github.com/actions/toolkit/tree/main/packages/core homepage: https://github.com/actions/toolkit/tree/main/packages/core

View File

@@ -1,6 +1,6 @@
--- ---
name: "@actions/exec" name: "@actions/exec"
version: 3.0.0 version: 1.1.1
type: npm type: npm
summary: Actions exec lib summary: Actions exec lib
homepage: https://github.com/actions/toolkit/tree/main/packages/exec homepage: https://github.com/actions/toolkit/tree/main/packages/exec

View File

@@ -1,6 +1,6 @@
--- ---
name: "@actions/github" name: "@actions/github"
version: 9.1.1 version: 6.0.0
type: npm type: npm
summary: Actions github lib summary: Actions github lib
homepage: https://github.com/actions/toolkit/tree/main/packages/github homepage: https://github.com/actions/toolkit/tree/main/packages/github

View File

@@ -1,32 +0,0 @@
---
name: "@actions/http-client"
version: 4.0.1
type: npm
summary: Actions Http Client
homepage: https://github.com/actions/toolkit/tree/main/packages/http-client
license: other
licenses:
- sources: LICENSE
text: |
Actions Http Client for Node.js
Copyright (c) GitHub, Inc.
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
notices: []

View File

@@ -1,10 +1,10 @@
--- ---
name: "@actions/http-client" name: "@actions/http-client"
version: 3.0.2 version: 2.2.1
type: npm type: npm
summary: Actions Http Client summary: Actions Http Client
homepage: https://github.com/actions/toolkit/tree/main/packages/http-client homepage: https://github.com/actions/toolkit/tree/main/packages/http-client
license: other license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE
text: | text: |

View File

@@ -1,6 +1,6 @@
--- ---
name: "@actions/io" name: "@actions/io"
version: 3.0.2 version: 1.1.3
type: npm type: npm
summary: Actions io lib summary: Actions io lib
homepage: https://github.com/actions/toolkit/tree/main/packages/io homepage: https://github.com/actions/toolkit/tree/main/packages/io

View File

@@ -1,6 +1,6 @@
--- ---
name: "@actions/tool-cache" name: "@actions/tool-cache"
version: 4.0.0 version: 2.0.1
type: npm type: npm
summary: Actions tool-cache lib summary: Actions tool-cache lib
homepage: https://github.com/actions/toolkit/tree/main/packages/tool-cache homepage: https://github.com/actions/toolkit/tree/main/packages/tool-cache

30
.licenses/npm/@fastify/busboy.dep.yml generated Normal file
View File

@@ -0,0 +1,30 @@
---
name: "@fastify/busboy"
version: 2.1.1
type: npm
summary: A streaming parser for HTML form data for node.js
homepage:
license: mit
licenses:
- sources: LICENSE
text: |-
Copyright Brian White. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
notices: []

View File

@@ -1,6 +1,6 @@
--- ---
name: "@octokit/auth-token" name: "@octokit/auth-token"
version: 6.0.0 version: 4.0.0
type: npm type: npm
summary: GitHub API token authentication for browsers and Node.js summary: GitHub API token authentication for browsers and Node.js
homepage: homepage:

View File

@@ -1,6 +1,6 @@
--- ---
name: "@octokit/core" name: "@octokit/core"
version: 7.0.6 version: 5.2.0
type: npm type: npm
summary: Extendable client for GitHub's REST & GraphQL APIs summary: Extendable client for GitHub's REST & GraphQL APIs
homepage: homepage:

View File

@@ -1,9 +1,9 @@
--- ---
name: "@octokit/endpoint" name: "@octokit/endpoint"
version: 11.0.3 version: 9.0.6
type: npm type: npm
summary: Turns REST API endpoints into generic request options summary: Turns REST API endpoints into generic request options
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE

View File

@@ -1,6 +1,6 @@
--- ---
name: "@octokit/graphql" name: "@octokit/graphql"
version: 9.0.3 version: 7.1.0
type: npm type: npm
summary: GitHub GraphQL API client for browsers and Node summary: GitHub GraphQL API client for browsers and Node
homepage: homepage:

View File

@@ -1,14 +1,14 @@
--- ---
name: "@octokit/openapi-types" name: "@octokit/openapi-types"
version: 27.0.0 version: 20.0.0
type: npm type: npm
summary: Generated TypeScript definitions based on GitHub's OpenAPI spec for api.github.com summary: Generated TypeScript definitions based on GitHub's OpenAPI spec for api.github.com
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE
text: | text: |-
Copyright (c) GitHub 2025 - Licensed as MIT. Copyright 2020 Gregor Martynus
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -0,0 +1,20 @@
---
name: "@octokit/openapi-types"
version: 22.1.0
type: npm
summary: Generated TypeScript definitions based on GitHub's OpenAPI spec for api.github.com
homepage:
license: mit
licenses:
- sources: LICENSE
text: |-
Copyright 2020 Gregor Martynus
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- sources: README.md
text: "[MIT](LICENSE)"
notices: []

View File

@@ -1,9 +1,9 @@
--- ---
name: "@octokit/plugin-paginate-rest" name: "@octokit/plugin-paginate-rest"
version: 14.0.0 version: 9.2.2
type: npm type: npm
summary: Octokit plugin to paginate REST API endpoint responses summary: Octokit plugin to paginate REST API endpoint responses
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE

View File

@@ -1,6 +1,6 @@
--- ---
name: "@octokit/plugin-rest-endpoint-methods" name: "@octokit/plugin-rest-endpoint-methods"
version: 17.0.0 version: 10.4.1
type: npm type: npm
summary: Octokit plugin adding one method for all of api.github.com REST API endpoints summary: Octokit plugin adding one method for all of api.github.com REST API endpoints
homepage: homepage:

View File

@@ -1,9 +1,9 @@
--- ---
name: "@octokit/request-error" name: "@octokit/request-error"
version: 7.1.0 version: 5.1.1
type: npm type: npm
summary: Error class for Octokit request errors summary: Error class for Octokit request errors
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE

View File

@@ -1,10 +1,10 @@
--- ---
name: "@octokit/request" name: "@octokit/request"
version: 10.0.10 version: 8.4.1
type: npm type: npm
summary: Send parameterized requests to GitHub's APIs with sensible defaults in browsers summary: Send parameterized requests to GitHub's APIs with sensible defaults in browsers
and Node and Node
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE

View File

@@ -1,6 +1,6 @@
--- ---
name: "@octokit/types" name: "@octokit/types"
version: 16.0.0 version: 12.6.0
type: npm type: npm
summary: Shared TypeScript definitions for Octokit projects summary: Shared TypeScript definitions for Octokit projects
homepage: homepage:

View File

@@ -0,0 +1,20 @@
---
name: "@octokit/types"
version: 13.4.1
type: npm
summary: Shared TypeScript definitions for Octokit projects
homepage:
license: mit
licenses:
- sources: LICENSE
text: |
MIT License Copyright (c) 2019 Octokit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- sources: README.md
text: "[MIT](LICENSE)"
notices: []

View File

@@ -1,6 +1,6 @@
--- ---
name: before-after-hook name: before-after-hook
version: 4.0.0 version: 2.2.3
type: npm type: npm
summary: asynchronous before/error/after hooks for internal functionality summary: asynchronous before/error/after hooks for internal functionality
homepage: homepage:

View File

@@ -1,47 +0,0 @@
---
name: content-type
version: 2.0.0
type: npm
summary: Create and parse HTTP Content-Type header
homepage:
license: mit
licenses:
- sources: LICENSE
text: |
(The MIT License)
Copyright (c) 2015 Douglas Christopher Wilson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- sources: README.md
text: |-
[MIT](LICENSE)
[npm-image]: https://img.shields.io/npm/v/content-type
[npm-url]: https://npmjs.org/package/content-type
[downloads-image]: https://img.shields.io/npm/dm/content-type
[downloads-url]: https://npmjs.org/package/content-type
[build-image]: https://img.shields.io/github/actions/workflow/status/jshttp/content-type/ci.yml?branch=master
[build-url]: https://github.com/jshttp/content-type/actions/workflows/ci.yml?query=branch%3Amaster
[coverage-image]: https://img.shields.io/codecov/c/gh/jshttp/content-type
[coverage-url]: https://codecov.io/gh/jshttp/content-type
[license-image]: http://img.shields.io/npm/l/content-type.svg?style=flat
[license-url]: LICENSE
notices: []

28
.licenses/npm/deprecation.dep.yml generated Normal file
View File

@@ -0,0 +1,28 @@
---
name: deprecation
version: 2.3.1
type: npm
summary: Log a deprecation message with stack
homepage: https://github.com/gr2m/deprecation#readme
license: isc
licenses:
- sources: LICENSE
text: |
The ISC License
Copyright (c) Gregor Martynus and contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- sources: README.md
text: "[ISC](LICENSE)"
notices: []

26
.licenses/npm/once.dep.yml generated Normal file
View File

@@ -0,0 +1,26 @@
---
name: once
version: 1.4.0
type: npm
summary: Run a function exactly one time
homepage: https://github.com/isaacs/once#readme
license: isc
licenses:
- sources: LICENSE
text: |
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
notices: []

View File

@@ -1,6 +1,6 @@
--- ---
name: semver name: semver
version: 7.8.4 version: 6.3.1
type: npm type: npm
summary: The semantic version parser used by npm. summary: The semantic version parser used by npm.
homepage: homepage:

View File

@@ -1,6 +1,6 @@
--- ---
name: undici name: undici
version: 6.27.0 version: 5.29.0
type: npm type: npm
summary: An HTTP/1.1 client, written from scratch for Node.js summary: An HTTP/1.1 client, written from scratch for Node.js
homepage: https://undici.nodejs.org homepage: https://undici.nodejs.org

View File

@@ -1,8 +1,8 @@
--- ---
name: universal-user-agent name: universal-user-agent
version: 7.0.3 version: 6.0.1
type: npm type: npm
summary: Get a user agent string across all JavaScript Runtime Environments summary: Get a user agent string in both browser and node
homepage: homepage:
license: isc license: isc
licenses: licenses:
@@ -10,7 +10,7 @@ licenses:
text: | text: |
# [ISC License](https://spdx.org/licenses/ISC) # [ISC License](https://spdx.org/licenses/ISC)
Copyright (c) 2018-2021, Gregor Martynus (https://github.com/gr2m) Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m)
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

View File

@@ -1,17 +1,16 @@
--- ---
name: json-with-bigint name: uuid
version: 3.5.8 version: 3.4.0
type: npm type: npm
summary: JS library that allows you to easily serialize and deserialize data with summary: RFC4122 (v1, v4, and v5) UUIDs
BigInt values
homepage: homepage:
license: mit license: mit
licenses: licenses:
- sources: LICENSE - sources: LICENSE.md
text: | text: |
MIT License The MIT License (MIT)
Copyright (c) 2023 Ivan Korolenko Copyright (c) 2010-2016 Robert Kieffer and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -30,4 +29,11 @@ licenses:
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
notices: [] notices:
- sources: AUTHORS
text: |-
Robert Kieffer <robert@broofa.com>
Christoph Tavan <dev@tavan.de>
AJ ONeal <coolaj86@gmail.com>
Vincent Voyer <vincent@zeroload.net>
Roman Shtylman <shtylman@gmail.com>

20
.licenses/npm/uuid-8.3.2.dep.yml generated Normal file
View File

@@ -0,0 +1,20 @@
---
name: uuid
version: 8.3.2
type: npm
summary: RFC4122 (v1, v4, and v5) UUIDs
homepage: https://github.com/uuidjs/uuid#readme
license: mit
licenses:
- sources: LICENSE.md
text: |
The MIT License (MIT)
Copyright (c) 2010-2020 Robert Kieffer and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
notices: []

20
.licenses/npm/uuid-9.0.1.dep.yml generated Normal file
View File

@@ -0,0 +1,20 @@
---
name: uuid
version: 9.0.1
type: npm
summary: RFC4122 (v1, v4, and v5) UUIDs
homepage:
license: mit
licenses:
- sources: LICENSE.md
text: |
The MIT License (MIT)
Copyright (c) 2010-2020 Robert Kieffer and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
notices: []

26
.licenses/npm/wrappy.dep.yml generated Normal file
View File

@@ -0,0 +1,26 @@
---
name: wrappy
version: 1.0.2
type: npm
summary: Callback wrapping utility
homepage: https://github.com/npm/wrappy
license: isc
licenses:
- sources: LICENSE
text: |
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
notices: []

View File

@@ -1,18 +1,5 @@
# Changelog # Changelog
## v7.0.0
* Block checking out fork PR for pull_request_target and workflow_run by @aiqiaoy in https://github.com/actions/checkout/pull/2454
* Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by @dependabot[bot] in https://github.com/actions/checkout/pull/2458
* Bump flatted from 3.3.1 to 3.4.2 by @dependabot[bot] in https://github.com/actions/checkout/pull/2460
* Bump js-yaml from 4.1.0 to 4.2.0 by @dependabot[bot] in https://github.com/actions/checkout/pull/2461
* Bump @actions/core and @actions/tool-cache and Remove uuid by @dependabot[bot] in https://github.com/actions/checkout/pull/2459
* upgrade module to esm and update dependencies by @aiqiaoy in https://github.com/actions/checkout/pull/2463
* Bump the minor-npm-dependencies group across 1 directory with 3 updates by @dependabot[bot] in https://github.com/actions/checkout/pull/2462
## 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 ## v6.0.2
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356 * Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356

View File

@@ -1,14 +1,5 @@
[![Build and Test](https://github.com/actions/checkout/actions/workflows/test.yml/badge.svg)](https://github.com/actions/checkout/actions/workflows/test.yml) [![Build and Test](https://github.com/actions/checkout/actions/workflows/test.yml/badge.svg)](https://github.com/actions/checkout/actions/workflows/test.yml)
# Checkout v7
## What's new
- Safer fork pull request handling: checkout now refuses to check out fork pull request code by default when the workflow is triggered by `pull_request_target` or `workflow_run`. These triggers run with the base repository's `GITHUB_TOKEN`, secrets, and runner access, where executing a fork's code commonly leads to "pwn request" vulnerabilities.
- To opt in after [reviewing the risks](https://gh.io/securely-using-pull_request_target), set the new `allow-unsafe-pr-checkout: true` input.
- Migrated `actions/checkout` to ESM to support new versions of the `@actions/*` packages.
- Updated direct and transitive dependencies, including security fixes for known vulnerabilities.
# Checkout v6 # Checkout v6
## What's new ## What's new
@@ -24,6 +15,7 @@
- Updated to the node24 runtime - Updated to the node24 runtime
- This requires a minimum Actions Runner version of [v2.327.1](https://github.com/actions/runner/releases/tag/v2.327.1) to run. - This requires a minimum Actions Runner version of [v2.327.1](https://github.com/actions/runner/releases/tag/v2.327.1) to run.
# Checkout v4 # Checkout v4
This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it. This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it.
@@ -60,7 +52,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
<!-- start usage --> <!-- start usage -->
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
# Repository name with owner. For example, actions/checkout # Repository name with owner. For example, actions/checkout
# Default: ${{ github.repository }} # Default: ${{ github.repository }}
@@ -169,14 +161,11 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# https://my-ghes-server.example.com # https://my-ghes-server.example.com
github-server-url: '' github-server-url: ''
# Required to check out fork pull request code from a workflow triggered by # Path to a local directory used as a reference cache for Git clones. Over time,
# `pull_request_target` or `workflow_run`. These workflows run with the base # this directory will contain bare clones of the checked-out repositories (and
# repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner # their submodules). Using this significantly reduces network bandwidth and speeds
# access; fetching and executing a fork's code in that trusted context commonly # up clones.
# leads to "pwn request" vulnerabilities. Set to `true` only after reviewing the reference-cache: ''
# risks at https://gh.io/securely-using-pull_request_target.
# Default: false
allow-unsafe-pr-checkout: ''
``` ```
<!-- end usage --> <!-- end usage -->
@@ -208,7 +197,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only the root files ## Fetch only the root files
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
sparse-checkout: . sparse-checkout: .
``` ```
@@ -216,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 ## Fetch only the root files and `.github` and `src` folder
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github .github
@@ -226,7 +215,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only a single file ## Fetch only a single file
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
README.md README.md
@@ -236,7 +225,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch all history for all tags and branches ## Fetch all history for all tags and branches
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
``` ```
@@ -244,7 +233,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout a different branch ## Checkout a different branch
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: my-branch ref: my-branch
``` ```
@@ -252,7 +241,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout HEAD^ ## Checkout HEAD^
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 2 fetch-depth: 2
- run: git checkout HEAD^ - run: git checkout HEAD^
@@ -262,12 +251,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml ```yaml
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
path: main path: main
- name: Checkout tools repo - name: Checkout tools repo
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
repository: my-org/my-tools repository: my-org/my-tools
path: my-tools path: my-tools
@@ -278,10 +267,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml ```yaml
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Checkout tools repo - name: Checkout tools repo
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
repository: my-org/my-tools repository: my-org/my-tools
path: my-tools path: my-tools
@@ -292,12 +281,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml ```yaml
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
path: main path: main
- name: Checkout private tools - name: Checkout private tools
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
repository: my-org/my-private-tools repository: my-org/my-private-tools
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
@@ -310,7 +299,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout pull request HEAD commit instead of merge commit ## Checkout pull request HEAD commit instead of merge commit
```yaml ```yaml
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
``` ```
@@ -326,7 +315,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
``` ```
## Push a commit using the built-in token ## Push a commit using the built-in token
@@ -337,7 +326,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- run: | - run: |
date > generated.txt date > generated.txt
# Note: the following account information will not work on GHES # Note: the following account information will not work on GHES
@@ -359,7 +348,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
- run: | - run: |

View File

@@ -1,46 +1,12 @@
import { import * as core from '@actions/core'
jest,
describe,
it,
expect,
beforeAll,
beforeEach,
afterEach,
afterAll
} from '@jest/globals'
import * as fs from 'fs' import * as fs from 'fs'
import * as gitAuthHelper from '../lib/git-auth-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as os from 'os' import * as os from 'os'
import * as path from 'path' import * as path from 'path'
import {fileURLToPath} from 'url' import * as stateHelper from '../lib/state-helper'
import {IGitCommandManager} from '../lib/git-command-manager'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) import {IGitSourceSettings} from '../lib/git-source-settings'
// Mock @actions/core before loading git-auth-helper
jest.unstable_mockModule('@actions/core', () => ({
setSecret: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
setFailed: jest.fn()
}))
// Mock state-helper
jest.unstable_mockModule('../src/state-helper.js', () => ({
setSshKeyPath: jest.fn(),
setSshKnownHostsPath: jest.fn(),
IsPost: false,
RepositoryPath: ''
}))
// Dynamic imports after mocking
const core = await import('@actions/core')
const gitAuthHelper = await import('../src/git-auth-helper.js')
type IGitCommandManager =
import('../src/git-command-manager.js').IGitCommandManager
type IGitSourceSettings =
import('../src/git-source-settings.js').IGitSourceSettings
const isWindows = process.platform === 'win32' const isWindows = process.platform === 'win32'
const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper') const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper')
@@ -66,12 +32,25 @@ describe('git-auth-helper tests', () => {
}) })
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() // Mock setSecret
jest.spyOn(core, 'setSecret').mockImplementation((secret: string) => {})
// Mock error/warning/info/debug
jest.spyOn(core, 'error').mockImplementation(jest.fn())
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
jest.spyOn(core, 'info').mockImplementation(jest.fn())
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
// Mock state helper
jest.spyOn(stateHelper, 'setSshKeyPath').mockImplementation(jest.fn())
jest
.spyOn(stateHelper, 'setSshKnownHostsPath')
.mockImplementation(jest.fn())
}) })
afterEach(() => { afterEach(() => {
// Unregister mocks // Unregister mocks
jest.clearAllMocks() jest.restoreAllMocks()
// Restore HOME // Restore HOME
if (originalHome) { if (originalHome) {
@@ -250,7 +229,7 @@ describe('git-auth-helper tests', () => {
await authHelper.configureAuth() await authHelper.configureAuth()
// Assert secret // Assert secret
const setSecretSpy = core.setSecret as jest.Mock<any> const setSecretSpy = core.setSecret as jest.Mock<any, any>
expect(setSecretSpy).toHaveBeenCalledTimes(1) expect(setSecretSpy).toHaveBeenCalledTimes(1)
const expectedSecret = Buffer.from( const expectedSecret = Buffer.from(
`x-access-token:${settings.authToken}`, `x-access-token:${settings.authToken}`,
@@ -550,7 +529,7 @@ describe('git-auth-helper tests', () => {
settings.sshKey = '' settings.sshKey = ''
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any> const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
@@ -583,7 +562,7 @@ describe('git-auth-helper tests', () => {
settings.persistCredentials = false settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any> const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
@@ -609,7 +588,7 @@ describe('git-auth-helper tests', () => {
settings.sshKey = '' settings.sshKey = ''
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any> const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
@@ -648,7 +627,7 @@ describe('git-auth-helper tests', () => {
) )
const authHelper = gitAuthHelper.createAuthHelper(git, settings) const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth() await authHelper.configureAuth()
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any> const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
mockSubmoduleForeach.mockClear() // reset calls mockSubmoduleForeach.mockClear() // reset calls
// Act // Act
@@ -830,7 +809,7 @@ describe('git-auth-helper tests', () => {
// Mock getSubmoduleConfigPaths to return our fake submodules (for both configure and remove) // Mock getSubmoduleConfigPaths to return our fake submodules (for both configure and remove)
const mockGetSubmoduleConfigPaths = const mockGetSubmoduleConfigPaths =
git.getSubmoduleConfigPaths as jest.Mock<any> git.getSubmoduleConfigPaths as jest.Mock<any, any>
mockGetSubmoduleConfigPaths.mockResolvedValue([ mockGetSubmoduleConfigPaths.mockResolvedValue([
submodule1ConfigPath, submodule1ConfigPath,
submodule2ConfigPath submodule2ConfigPath
@@ -1068,8 +1047,10 @@ async function setup(testName: string): Promise<void> {
lfsFetch: jest.fn(), lfsFetch: jest.fn(),
lfsInstall: jest.fn(), lfsInstall: jest.fn(),
log1: jest.fn(), log1: jest.fn(),
referenceAdd: jest.fn(),
remoteAdd: jest.fn(), remoteAdd: jest.fn(),
removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]), removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]),
execGit: jest.fn(),
revParse: jest.fn(), revParse: jest.fn(),
setEnvironmentVariable: jest.fn((name: string, value: string) => { setEnvironmentVariable: jest.fn((name: string, value: string) => {
git.env[name] = value git.env[name] = value
@@ -1168,7 +1149,7 @@ async function setup(testName: string): Promise<void> {
), ),
tryReset: jest.fn(), tryReset: jest.fn(),
version: jest.fn() version: jest.fn()
} as unknown as IGitCommandManager & {env: {[key: string]: string}} }
settings = { settings = {
authToken: 'some auth token', authToken: 'some auth token',
@@ -1178,6 +1159,7 @@ async function setup(testName: string): Promise<void> {
sparseCheckout: [], sparseCheckout: [],
sparseCheckoutConeMode: true, sparseCheckoutConeMode: true,
fetchDepth: 1, fetchDepth: 1,
fetchDepthExplicit: false,
fetchTags: false, fetchTags: false,
showProgress: true, showProgress: true,
lfs: false, lfs: false,
@@ -1195,7 +1177,7 @@ async function setup(testName: string): Promise<void> {
workflowOrganizationId: 123456, workflowOrganizationId: 123456,
setSafeDirectory: true, setSafeDirectory: true,
githubServerUrl: githubServerUrl, githubServerUrl: githubServerUrl,
allowUnsafePrCheckout: false referenceCache: ''
} }
} }

View File

@@ -0,0 +1,109 @@
import * as path from 'path'
import * as fs from 'fs'
import * as io from '@actions/io'
import { GitCacheHelper } from '../src/git-cache-helper'
import { IGitCommandManager } from '../src/git-command-manager'
describe('GitCacheHelper', () => {
let cacheHelper: GitCacheHelper
let mockGit: jest.Mocked<IGitCommandManager>
const cacheDir = path.join(__dirname, 'test-cache')
beforeEach(async () => {
cacheHelper = new GitCacheHelper(cacheDir)
mockGit = {
execGit: jest.fn().mockImplementation(async (args) => {
// If git clone is called, simulate creating the destination dir
if (args && args.includes('clone')) {
const dest = args.find((a: string) => a.includes('.tmp.'));
if (dest) {
await io.mkdirP(dest);
} else {
console.log('No .tmp. found in args:', args);
}
}
return { exitCode: 0, stdout: '', stderr: '' };
}),
gitEnv: {}
} as any
await io.mkdirP(cacheDir)
})
afterEach(async () => {
await io.rmRF(cacheDir)
})
it('generates a consistent, short, and safe cache directory name', () => {
const url1 = 'https://github.com/mwyraz/forgejo-actions-checkout.git'
const name1 = (cacheHelper as any).generateCacheDirName(url1)
// Check structure: safe string + hash
expect(name1).toMatch(/^https___github_com_mwyraz_forgejo_actions_checkout_git_[0-9a-f]{8}\.git$/)
// Same URL should produce the same directory name
const url1_duplicate = 'https://github.com/mwyraz/forgejo-actions-checkout.git'
expect((cacheHelper as any).generateCacheDirName(url1_duplicate)).toBe(name1)
// Different URL should produce a different directory name
const url2 = 'https://github.com/mwyraz/forgejo-actions-checkout-other.git'
expect((cacheHelper as any).generateCacheDirName(url2)).not.toBe(name1)
// SSH URL
const url3 = 'git@github.com:auth/repo.git'
const name3 = (cacheHelper as any).generateCacheDirName(url3)
expect(name3).toMatch(/^git_github_com_auth_repo_git_[0-9a-f]{8}\.git$/)
// Unclean URLs
const url4 = 'https://github.com/foo/bar.git?v=1'
const name4 = (cacheHelper as any).generateCacheDirName(url4)
expect(name4).toMatch(/^https___github_com_foo_bar_git_v_1_[0-9a-f]{8}\.git$/)
})
it('sets up a cache directory if it does not exist', async () => {
const repositoryUrl = 'https://github.com/mwyraz/test-repo.git'
const resultPath = await cacheHelper.setupCache(mockGit, repositoryUrl)
const expectedName = (cacheHelper as any).generateCacheDirName(repositoryUrl)
expect(resultPath).toBe(path.join(cacheDir, expectedName))
// It should have executed git clone --bare
expect(mockGit.execGit).toHaveBeenCalledWith(
expect.arrayContaining([
'-C',
cacheDir,
'clone',
'--bare',
repositoryUrl,
expect.stringContaining(`${expectedName}.tmp`) // should use tmp dir
])
)
})
it('fetches updates if the cache directory already exists', async () => {
const repositoryUrl = 'https://github.com/mwyraz/existing-repo.git'
const expectedName = (cacheHelper as any).generateCacheDirName(repositoryUrl)
const fixedPath = path.join(cacheDir, expectedName)
// Fake existing directory
await io.mkdirP(path.join(fixedPath, 'objects'))
const resultPath = await cacheHelper.setupCache(mockGit, repositoryUrl)
expect(resultPath).toBe(fixedPath)
// It should have executed git fetch
expect(mockGit.execGit).toHaveBeenCalledWith(
expect.arrayContaining([
'-C',
fixedPath,
'fetch',
'--force',
'--prune',
'--tags',
'origin',
'+refs/heads/*:refs/heads/*'
])
)
})
})

View File

@@ -1,51 +1,26 @@
import { import * as exec from '@actions/exec'
jest, import * as fshelper from '../lib/fs-helper'
describe, import * as commandManager from '../lib/git-command-manager'
it,
expect,
beforeAll,
beforeEach,
afterEach,
afterAll
} from '@jest/globals'
// Mock @actions/exec let git: commandManager.IGitCommandManager
const mockExec = jest.fn() let mockExec = jest.fn()
jest.unstable_mockModule('@actions/exec', () => ({
exec: mockExec
}))
// Mock fs-helper
const mockFileExistsSync = jest.fn()
const mockDirectoryExistsSync = jest.fn()
jest.unstable_mockModule('../src/fs-helper.js', () => ({
fileExistsSync: mockFileExistsSync,
directoryExistsSync: mockDirectoryExistsSync
}))
// Dynamic imports after mocking
const commandManager = await import('../src/git-command-manager.js')
type IGitCommandManager =
import('../src/git-command-manager.js').IGitCommandManager
let git: IGitCommandManager
describe('git-auth-helper tests', () => { describe('git-auth-helper tests', () => {
beforeAll(async () => {}) beforeAll(async () => {})
beforeEach(async () => { beforeEach(async () => {
mockFileExistsSync.mockReset() jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
mockDirectoryExistsSync.mockReset() jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.restoreAllMocks()
}) })
afterAll(() => {}) afterAll(() => {})
it('branch list matches', async () => { it('branch list matches', async () => {
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
console.log(args, options.listeners.stdout) console.log(args, options.listeners.stdout)
if (args.includes('version')) { if (args.includes('version')) {
@@ -61,7 +36,7 @@ describe('git-auth-helper tests', () => {
return 1 return 1
}) })
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
const doSparseCheckout = false const doSparseCheckout = false
@@ -78,7 +53,7 @@ describe('git-auth-helper tests', () => {
}) })
it('ambiguous ref name output is captured', async () => { it('ambiguous ref name output is captured', async () => {
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
console.log(args, options.listeners.stdout) console.log(args, options.listeners.stdout)
if (args.includes('version')) { if (args.includes('version')) {
@@ -97,7 +72,7 @@ describe('git-auth-helper tests', () => {
return 1 return 1
}) })
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
const doSparseCheckout = false const doSparseCheckout = false
@@ -116,9 +91,9 @@ describe('git-auth-helper tests', () => {
describe('Test fetchDepth and fetchTags options', () => { describe('Test fetchDepth and fetchTags options', () => {
beforeEach(async () => { beforeEach(async () => {
mockFileExistsSync.mockReset() jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
mockDirectoryExistsSync.mockReset() jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
console.log(args, options.listeners.stdout) console.log(args, options.listeners.stdout)
if (args.includes('version')) { if (args.includes('version')) {
@@ -130,11 +105,11 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() 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', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
const doSparseCheckout = false const doSparseCheckout = false
@@ -171,7 +146,7 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => { it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -209,7 +184,7 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
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', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -247,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 refSpec includes tags', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -286,7 +261,7 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
it('should call execGit with the correct arguments when showProgress is true', async () => { it('should call execGit with the correct arguments when showProgress is true', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -324,7 +299,7 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
it('should call execGit with the correct arguments when fetchDepth is 42 and showProgress is true', async () => { it('should call execGit with the correct arguments when fetchDepth is 42 and showProgress is true', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -364,7 +339,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 showProgress is true and refSpec includes tags', async () => {
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -403,67 +378,14 @@ describe('Test fetchDepth and fetchTags options', () => {
}) })
}) })
describe('repository initialization object format', () => {
beforeEach(async () => {
mockFileExistsSync.mockReset()
mockDirectoryExistsSync.mockReset()
})
afterEach(() => {
jest.clearAllMocks()
})
it('initializes SHA-256 repositories with the matching object format', async () => {
mockExec.mockImplementation((path: any, args: any, options: any) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
return 0
})
// exec.exec is already 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: any, args: any, options: any) => {
if (args.includes('version')) {
options.listeners.stdout(Buffer.from('git version 2.50.1'))
}
return 0
})
// exec.exec is already 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', () => { describe('git user-agent with orchestration ID', () => {
beforeEach(async () => { beforeEach(async () => {
mockFileExistsSync.mockReset() jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
mockDirectoryExistsSync.mockReset() jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.restoreAllMocks()
// Clean up environment variable to prevent test pollution // Clean up environment variable to prevent test pollution
delete process.env['ACTIONS_ORCHESTRATION_ID'] delete process.env['ACTIONS_ORCHESTRATION_ID']
}) })
@@ -473,7 +395,7 @@ describe('git user-agent with orchestration ID', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
let capturedEnv: any = null let capturedEnv: any = null
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) { if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18')) options.listeners.stdout(Buffer.from('2.18'))
} }
@@ -481,7 +403,7 @@ describe('git user-agent with orchestration ID', () => {
capturedEnv = options.env capturedEnv = options.env
return 0 return 0
}) })
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -508,7 +430,7 @@ describe('git user-agent with orchestration ID', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
let capturedEnv: any = null let capturedEnv: any = null
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) { if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18')) options.listeners.stdout(Buffer.from('2.18'))
} }
@@ -516,7 +438,7 @@ describe('git user-agent with orchestration ID', () => {
capturedEnv = options.env capturedEnv = options.env
return 0 return 0
}) })
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false
@@ -542,7 +464,7 @@ describe('git user-agent with orchestration ID', () => {
delete process.env['ACTIONS_ORCHESTRATION_ID'] delete process.env['ACTIONS_ORCHESTRATION_ID']
let capturedEnv: any = null let capturedEnv: any = null
mockExec.mockImplementation((path: any, args: any, options: any) => { mockExec.mockImplementation((path, args, options) => {
if (args.includes('version')) { if (args.includes('version')) {
options.listeners.stdout(Buffer.from('2.18')) options.listeners.stdout(Buffer.from('2.18'))
} }
@@ -550,7 +472,7 @@ describe('git user-agent with orchestration ID', () => {
capturedEnv = options.env capturedEnv = options.env
return 0 return 0
}) })
// exec.exec is already mockExec jest.spyOn(exec, 'exec').mockImplementation(mockExec)
const workingDirectory = 'test' const workingDirectory = 'test'
const lfs = false const lfs = false

View File

@@ -1,36 +1,9 @@
import { import * as core from '@actions/core'
jest,
describe,
it,
expect,
beforeAll,
beforeEach,
afterEach
} from '@jest/globals'
import * as fs from 'fs' import * as fs from 'fs'
import * as gitDirectoryHelper from '../lib/git-directory-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as path from 'path' import * as path from 'path'
import {fileURLToPath} from 'url' import {IGitCommandManager} from '../lib/git-command-manager'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Mock @actions/core before loading git-directory-helper
jest.unstable_mockModule('@actions/core', () => ({
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
setFailed: jest.fn(),
startGroup: jest.fn(),
endGroup: jest.fn()
}))
// Dynamic imports after mocking
const core = await import('@actions/core')
const gitDirectoryHelper = await import('../src/git-directory-helper.js')
type IGitCommandManager =
import('../src/git-command-manager.js').IGitCommandManager
const testWorkspace = path.join(__dirname, '_temp', 'git-directory-helper') const testWorkspace = path.join(__dirname, '_temp', 'git-directory-helper')
let repositoryPath: string let repositoryPath: string
@@ -46,11 +19,16 @@ describe('git-directory-helper tests', () => {
}) })
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() // Mock error/warning/info/debug
jest.spyOn(core, 'error').mockImplementation(jest.fn())
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
jest.spyOn(core, 'info').mockImplementation(jest.fn())
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() // Unregister mocks
jest.restoreAllMocks()
}) })
const cleansWhenCleanTrue = 'cleans when clean true' const cleansWhenCleanTrue = 'cleans when clean true'
@@ -103,7 +81,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(doesNotCheckoutDetachWhenNotAlreadyDetached) await setup(doesNotCheckoutDetachWhenNotAlreadyDetached)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
const mockIsDetached = git.isDetached as jest.Mock<any> const mockIsDetached = git.isDetached as jest.Mock<any, any>
mockIsDetached.mockImplementation(async () => { mockIsDetached.mockImplementation(async () => {
return true return true
}) })
@@ -154,7 +132,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(removesContentsWhenCleanFails) await setup(removesContentsWhenCleanFails)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
let mockTryClean = git.tryClean as jest.Mock<any> let mockTryClean = git.tryClean as jest.Mock<any, any>
mockTryClean.mockImplementation(async () => { mockTryClean.mockImplementation(async () => {
return false return false
}) })
@@ -232,7 +210,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(removesContentsWhenResetFails) await setup(removesContentsWhenResetFails)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
let mockTryReset = git.tryReset as jest.Mock<any> let mockTryReset = git.tryReset as jest.Mock<any, any>
mockTryReset.mockImplementation(async () => { mockTryReset.mockImplementation(async () => {
return false return false
}) })
@@ -282,7 +260,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(removesLocalBranches) await setup(removesLocalBranches)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
const mockBranchList = git.branchList as jest.Mock<any> const mockBranchList = git.branchList as jest.Mock<any, any>
mockBranchList.mockImplementation(async (remote: boolean) => { mockBranchList.mockImplementation(async (remote: boolean) => {
return remote ? [] : ['local-branch-1', 'local-branch-2'] return remote ? [] : ['local-branch-1', 'local-branch-2']
}) })
@@ -313,7 +291,7 @@ describe('git-directory-helper tests', () => {
//mock bad submodule //mock bad submodule
const submoduleStatus = git.submoduleStatus as jest.Mock<any> const submoduleStatus = git.submoduleStatus as jest.Mock<any, any>
submoduleStatus.mockImplementation(async (remote: boolean) => { submoduleStatus.mockImplementation(async (remote: boolean) => {
return false return false
}) })
@@ -341,7 +319,7 @@ describe('git-directory-helper tests', () => {
await setup(doesNotCleanWhenSubmoduleStatusIsTrue) await setup(doesNotCleanWhenSubmoduleStatusIsTrue)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
const submoduleStatus = git.submoduleStatus as jest.Mock<any> const submoduleStatus = git.submoduleStatus as jest.Mock<any, any>
submoduleStatus.mockImplementation(async (remote: boolean) => { submoduleStatus.mockImplementation(async (remote: boolean) => {
return true return true
}) })
@@ -403,7 +381,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(removesAncestorRemoteBranch) await setup(removesAncestorRemoteBranch)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
const mockBranchList = git.branchList as jest.Mock<any> const mockBranchList = git.branchList as jest.Mock<any, any>
mockBranchList.mockImplementation(async (remote: boolean) => { mockBranchList.mockImplementation(async (remote: boolean) => {
return remote ? ['origin/remote-branch-1', 'origin/remote-branch-2'] : [] return remote ? ['origin/remote-branch-1', 'origin/remote-branch-2'] : []
}) })
@@ -433,7 +411,7 @@ describe('git-directory-helper tests', () => {
// Arrange // Arrange
await setup(removesDescendantRemoteBranches) await setup(removesDescendantRemoteBranches)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
const mockBranchList = git.branchList as jest.Mock<any> const mockBranchList = git.branchList as jest.Mock<any, any>
mockBranchList.mockImplementation(async (remote: boolean) => { mockBranchList.mockImplementation(async (remote: boolean) => {
return remote return remote
? ['origin/remote-branch-1/conflict', 'origin/remote-branch-2'] ? ['origin/remote-branch-1/conflict', 'origin/remote-branch-2']
@@ -500,8 +478,10 @@ async function setup(testName: string): Promise<void> {
lfsFetch: jest.fn(), lfsFetch: jest.fn(),
lfsInstall: jest.fn(), lfsInstall: jest.fn(),
log1: jest.fn(), log1: jest.fn(),
referenceAdd: jest.fn(),
remoteAdd: jest.fn(), remoteAdd: jest.fn(),
removeEnvironmentVariable: jest.fn(), removeEnvironmentVariable: jest.fn(),
execGit: jest.fn(),
revParse: jest.fn(), revParse: jest.fn(),
setEnvironmentVariable: jest.fn(), setEnvironmentVariable: jest.fn(),
shaExists: jest.fn(), shaExists: jest.fn(),
@@ -529,5 +509,5 @@ async function setup(testName: string): Promise<void> {
return true return true
}), }),
version: jest.fn() version: jest.fn()
} as unknown as IGitCommandManager }
} }

View File

@@ -0,0 +1,88 @@
import * as core from '@actions/core'
import {adjustFetchDepthForCache} from '../src/git-source-provider'
// Mock @actions/core
jest.mock('@actions/core')
describe('adjustFetchDepthForCache', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('does nothing when referenceCache is not set', () => {
const settings = {
referenceCache: '',
fetchDepth: 1,
fetchDepthExplicit: false
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(1)
expect(core.warning).not.toHaveBeenCalled()
expect(core.info).not.toHaveBeenCalled()
})
it('overrides fetchDepth to 0 when referenceCache is set and fetchDepth is default', () => {
const settings = {
referenceCache: '/cache/git-reference-cache',
fetchDepth: 1,
fetchDepthExplicit: false
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(0)
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('Overriding fetch-depth from 1 to 0')
)
expect(core.warning).not.toHaveBeenCalled()
})
it('warns but keeps fetchDepth when referenceCache is set and fetchDepth is explicit', () => {
const settings = {
referenceCache: '/cache/git-reference-cache',
fetchDepth: 1,
fetchDepthExplicit: true
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(1)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining("'fetch-depth: 1' is set with reference-cache enabled")
)
expect(core.info).not.toHaveBeenCalled()
})
it('does nothing when referenceCache is set and fetchDepth is already 0 (explicit)', () => {
const settings = {
referenceCache: '/cache/git-reference-cache',
fetchDepth: 0,
fetchDepthExplicit: true
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(0)
expect(core.warning).not.toHaveBeenCalled()
expect(core.info).not.toHaveBeenCalled()
})
it('does nothing when referenceCache is set and fetchDepth is already 0 (default)', () => {
const settings = {
referenceCache: '/cache/git-reference-cache',
fetchDepth: 0,
fetchDepthExplicit: false
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(0)
expect(core.warning).not.toHaveBeenCalled()
expect(core.info).not.toHaveBeenCalled()
})
it('warns with correct depth value when explicit fetchDepth is > 1', () => {
const settings = {
referenceCache: '/cache/git-reference-cache',
fetchDepth: 42,
fetchDepthExplicit: true
}
adjustFetchDepthForCache(settings)
expect(settings.fetchDepth).toBe(42)
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining("'fetch-depth: 42' is set with reference-cache enabled")
)
})
})

View File

@@ -1,6 +1,5 @@
import {describe, it, expect} from '@jest/globals' import {GitVersion} from '../src/git-version'
import {GitVersion} from '../src/git-version.js' import {MinimumGitSparseCheckoutVersion} from '../src/git-command-manager'
import {MinimumGitSparseCheckoutVersion} from '../src/git-command-manager.js'
describe('git-version tests', () => { describe('git-version tests', () => {
it('basics', async () => { it('basics', async () => {

View File

@@ -1,112 +0,0 @@
import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'
// Mock @actions/core
const mockDebug = jest.fn()
jest.unstable_mockModule('@actions/core', () => ({
debug: mockDebug,
info: jest.fn(),
warning: jest.fn(),
error: jest.fn()
}))
// Mock @actions/github
const mockGetOctokit = jest.fn()
jest.unstable_mockModule('@actions/github', () => ({
getOctokit: mockGetOctokit
}))
// Dynamic imports after mocking
const githubApiHelper = await import('../src/github-api-helper.js')
describe('github-api-helper object format', () => {
let request: jest.Mock<any>
function mockHashAlgorithmApi(hashAlgorithm: string): void {
request = jest.fn(async () => ({
data: {
hash_algorithm: hashAlgorithm
}
}))
mockGetOctokit.mockReturnValue({
request
} as any)
}
beforeEach(() => {
mockDebug.mockClear()
mockGetOctokit.mockClear()
})
afterEach(() => {
jest.clearAllMocks()
})
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(mockGetOctokit).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'
await expect(
githubApiHelper.tryGetRepositoryObjectFormat(
'token',
'owner',
'repo',
undefined,
commitSha
)
).resolves.toEqual({format: 'sha256', succeeded: true})
expect(mockGetOctokit).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(mockDebug).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')
})
mockGetOctokit.mockReturnValue({
request
} as any)
await expect(
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
).resolves.toEqual({format: '', succeeded: false})
expect(mockDebug).toHaveBeenCalledWith(
'Unable to determine repository object format from hash-algorithm endpoint: not found'
)
})
})

View File

@@ -1,13 +1,10 @@
import { import * as core from '@actions/core'
jest, import * as fsHelper from '../lib/fs-helper'
describe, import * as github from '@actions/github'
it, import * as inputHelper from '../lib/input-helper'
expect,
beforeAll,
beforeEach,
afterAll
} from '@jest/globals'
import * as path from 'path' import * as path from 'path'
import * as workflowContextHelper from '../lib/workflow-context-helper'
import {IGitSourceSettings} from '../lib/git-source-settings'
const originalGitHubWorkspace = process.env['GITHUB_WORKSPACE'] const originalGitHubWorkspace = process.env['GITHUB_WORKSPACE']
const gitHubWorkspace = path.resolve('/checkout-tests/workspace') const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
@@ -15,58 +12,42 @@ const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
// Inputs for mock @actions/core // Inputs for mock @actions/core
let inputs = {} as any let inputs = {} as any
// Mutable mock github context // Shallow clone original @actions/github context
const mockGithubContext: any = { let originalContext = {...github.context}
ref: 'refs/heads/some-ref',
sha: '1234567890123456789012345678901234567890',
repo: {owner: 'some-owner', repo: 'some-repo'},
eventName: '',
payload: {}
}
// Mock @actions/core before loading input-helper
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn((name: string) => inputs[name]),
getBooleanInput: jest.fn((name: string) => inputs[name]),
getMultilineInput: jest.fn((name: string) =>
inputs[name] ? String(inputs[name]).split('\n').filter(Boolean) : []
),
error: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
setFailed: jest.fn(),
setOutput: jest.fn(),
setSecret: jest.fn()
}))
// Mock @actions/github before loading input-helper
jest.unstable_mockModule('@actions/github', () => ({
context: mockGithubContext,
getOctokit: jest.fn()
}))
// Mock fs-helper
const mockDirectoryExistsSync = jest.fn((p: string) => p === gitHubWorkspace)
jest.unstable_mockModule('../src/fs-helper.js', () => ({
directoryExistsSync: mockDirectoryExistsSync,
fileExistsSync: jest.fn()
}))
// Mock workflow-context-helper
const mockGetOrganizationId = jest.fn(async () => 123456)
jest.unstable_mockModule('../src/workflow-context-helper.js', () => ({
getOrganizationId: mockGetOrganizationId
}))
// Dynamic imports after mocking
const core = await import('@actions/core')
const inputHelper = await import('../src/input-helper.js')
type IGitSourceSettings =
import('../src/git-source-settings.js').IGitSourceSettings
describe('input-helper tests', () => { describe('input-helper tests', () => {
beforeAll(() => { beforeAll(() => {
// Mock getInput
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
return inputs[name]
})
// Mock error/warning/info/debug
jest.spyOn(core, 'error').mockImplementation(jest.fn())
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
jest.spyOn(core, 'info').mockImplementation(jest.fn())
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
// Mock github context
jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => {
return {
owner: 'some-owner',
repo: 'some-repo'
}
})
github.context.ref = 'refs/heads/some-ref'
github.context.sha = '1234567890123456789012345678901234567890'
// Mock ./fs-helper directoryExistsSync()
jest
.spyOn(fsHelper, 'directoryExistsSync')
.mockImplementation((path: string) => path == gitHubWorkspace)
// Mock ./workflowContextHelper getOrganizationId()
jest
.spyOn(workflowContextHelper, 'getOrganizationId')
.mockImplementation(() => Promise.resolve(123456))
// GitHub workspace // GitHub workspace
process.env['GITHUB_WORKSPACE'] = gitHubWorkspace process.env['GITHUB_WORKSPACE'] = gitHubWorkspace
}) })
@@ -74,15 +55,6 @@ describe('input-helper tests', () => {
beforeEach(() => { beforeEach(() => {
// Reset inputs // Reset inputs
inputs = {} inputs = {}
jest.clearAllMocks()
// Re-apply default mocks
;(core.getInput as jest.Mock<any>).mockImplementation(
(name: string) => inputs[name]
)
mockDirectoryExistsSync.mockImplementation(
(p: string) => p === gitHubWorkspace
)
mockGetOrganizationId.mockResolvedValue(123456)
}) })
afterAll(() => { afterAll(() => {
@@ -93,8 +65,11 @@ describe('input-helper tests', () => {
} }
// Restore @actions/github context // Restore @actions/github context
mockGithubContext.ref = 'refs/heads/some-ref' github.context.ref = originalContext.ref
mockGithubContext.sha = '1234567890123456789012345678901234567890' github.context.sha = originalContext.sha
// Restore
jest.restoreAllMocks()
}) })
it('sets defaults', async () => { it('sets defaults', async () => {
@@ -116,19 +91,19 @@ describe('input-helper tests', () => {
expect(settings.repositoryOwner).toBe('some-owner') expect(settings.repositoryOwner).toBe('some-owner')
expect(settings.repositoryPath).toBe(gitHubWorkspace) expect(settings.repositoryPath).toBe(gitHubWorkspace)
expect(settings.setSafeDirectory).toBe(true) expect(settings.setSafeDirectory).toBe(true)
expect(settings.allowUnsafePrCheckout).toBe(false) expect(settings.referenceCache || '').toBe('')
}) })
it('qualifies ref', async () => { it('qualifies ref', async () => {
let originalRef = mockGithubContext.ref let originalRef = github.context.ref
try { try {
mockGithubContext.ref = 'some-unqualified-ref' github.context.ref = 'some-unqualified-ref'
const settings: IGitSourceSettings = await inputHelper.getInputs() const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings).toBeTruthy() expect(settings).toBeTruthy()
expect(settings.commit).toBe('1234567890123456789012345678901234567890') expect(settings.commit).toBe('1234567890123456789012345678901234567890')
expect(settings.ref).toBe('refs/heads/some-unqualified-ref') expect(settings.ref).toBe('refs/heads/some-unqualified-ref')
} finally { } finally {
mockGithubContext.ref = originalRef github.context.ref = originalRef
} }
}) })
@@ -159,16 +134,6 @@ describe('input-helper tests', () => {
expect(settings.commit).toBe('1111111111222222222233333333334444444444') 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 () => { it('sets sha to empty when explicit ref', async () => {
inputs.ref = 'refs/heads/some-other-ref' inputs.ref = 'refs/heads/some-other-ref'
const settings: IGitSourceSettings = await inputHelper.getInputs() const settings: IGitSourceSettings = await inputHelper.getInputs()

View File

@@ -1,46 +1,13 @@
import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'
import * as assert from 'assert' import * as assert from 'assert'
import * as refHelper from '../lib/ref-helper'
// Mutable mock github context import {IGitCommandManager} from '../lib/git-command-manager'
const mockGithubContext: any = {
eventName: '',
payload: {},
repo: {owner: 'some-owner', repo: 'some-repo'},
ref: '',
sha: ''
}
// Mock @actions/core
const mockDebug = jest.fn()
jest.unstable_mockModule('@actions/core', () => ({
debug: mockDebug,
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setFailed: jest.fn()
}))
// Mock @actions/github
const mockGetOctokit = jest.fn()
jest.unstable_mockModule('@actions/github', () => ({
context: mockGithubContext,
getOctokit: mockGetOctokit
}))
// Dynamic imports after mocking
const refHelper = await import('../src/ref-helper.js')
type IGitCommandManager =
import('../src/git-command-manager.js').IGitCommandManager
const commit = '1234567890123456789012345678901234567890' const commit = '1234567890123456789012345678901234567890'
const sha256Commit =
'1234567890123456789012345678901234567890123456789012345678901234'
let git: IGitCommandManager let git: IGitCommandManager
describe('ref-helper tests', () => { describe('ref-helper tests', () => {
beforeEach(() => { beforeEach(() => {
git = {} as unknown as IGitCommandManager git = {} as unknown as IGitCommandManager
jest.clearAllMocks()
}) })
it('getCheckoutInfo requires git', async () => { it('getCheckoutInfo requires git', async () => {
@@ -70,12 +37,6 @@ describe('ref-helper tests', () => {
expect(checkoutInfo.startPoint).toBeFalsy() 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 () => { it('getCheckoutInfo refs/heads/', async () => {
const checkoutInfo = await refHelper.getCheckoutInfo( const checkoutInfo = await refHelper.getCheckoutInfo(
git, git,
@@ -195,12 +156,14 @@ describe('ref-helper tests', () => {
}) })
it('getRefSpec sha + refs/tags/ with fetchTags', async () => { 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) const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit, true)
expect(refSpec.length).toBe(1) expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
}) })
it('getRefSpec sha + refs/heads/ with fetchTags', async () => { 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) const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit, true)
expect(refSpec.length).toBe(2) expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
@@ -221,6 +184,7 @@ describe('ref-helper tests', () => {
}) })
it('getRefSpec unqualified ref only with fetchTags', async () => { 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) const refSpec = refHelper.getRefSpec('my-ref', '', true)
expect(refSpec.length).toBe(2) expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
@@ -248,12 +212,14 @@ describe('ref-helper tests', () => {
}) })
it('getRefSpec refs/tags/ only with fetchTags', async () => { 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) const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '', true)
expect(refSpec.length).toBe(1) expect(refSpec.length).toBe(1)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
}) })
it('getRefSpec refs/heads/ only with fetchTags', async () => { 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) const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '', true)
expect(refSpec.length).toBe(2) expect(refSpec.length).toBe(2)
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*') expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
@@ -261,140 +227,4 @@ describe('ref-helper tests', () => {
'+refs/heads/my/branch:refs/remotes/origin/my/branch' '+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 repoGetSpy: jest.Mock<any>
let originalEventName: string
let originalPayload: unknown
let originalRef: string
let originalSha: string
function setPullRequestContext(
expectedHeadSha: string,
expectedBaseSha: string,
mergeCommit: string
): void {
mockGithubContext.eventName = 'pull_request'
mockGithubContext.ref = ref
mockGithubContext.sha = mergeCommit
mockGithubContext.payload = {
action: 'synchronize',
after: expectedHeadSha,
number: 123,
pull_request: {
base: {
sha: expectedBaseSha
}
},
repository: {
private: false
}
}
}
beforeEach(() => {
originalEventName = mockGithubContext.eventName
originalPayload = mockGithubContext.payload
originalRef = mockGithubContext.ref
originalSha = mockGithubContext.sha
mockGithubContext.repo = {
owner: repositoryOwner,
repo: repositoryName
}
repoGetSpy = jest.fn(async () => ({}))
mockGetOctokit.mockReturnValue({
rest: {
repos: {
get: repoGetSpy
}
}
} as any)
})
afterEach(() => {
mockGithubContext.eventName = originalEventName
mockGithubContext.payload = originalPayload
mockGithubContext.ref = originalRef
mockGithubContext.sha = originalSha
jest.clearAllMocks()
})
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(mockGetOctokit).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(mockGetOctokit).toHaveBeenCalledWith(
'token',
expect.objectContaining({
userAgent: expect.stringContaining(
`expected_head_sha=${sha256Head};actual_head_sha=${actualHeadSha}`
)
})
)
expect(repoGetSpy).toHaveBeenCalledWith({
owner: repositoryOwner,
repo: repositoryName
})
expect(mockDebug).toHaveBeenCalledWith(
`Expected head sha ${sha256Head}; actual head sha ${actualHeadSha}`
)
expect(mockDebug).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(mockGetOctokit).not.toHaveBeenCalled()
expect(repoGetSpy).not.toHaveBeenCalled()
expect(mockDebug).toHaveBeenCalledWith('Unexpected message format')
})
})
}) })

View File

@@ -1,32 +1,16 @@
import { import * as core from '@actions/core'
jest, import {RetryHelper} from '../lib/retry-helper'
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll
} from '@jest/globals'
let info: string[] = []
// Mock @actions/core before loading retry-helper
jest.unstable_mockModule('@actions/core', () => ({
info: jest.fn((message: string) => {
info.push(message)
}),
debug: jest.fn(),
warning: jest.fn(),
error: jest.fn()
}))
// Dynamic imports after mocking
const {RetryHelper} = await import('../src/retry-helper.js')
let info: string[]
let retryHelper: any let retryHelper: any
describe('retry-helper tests', () => { describe('retry-helper tests', () => {
beforeAll(() => { beforeAll(() => {
// Mock @actions/core info()
jest.spyOn(core, 'info').mockImplementation((message: string) => {
info.push(message)
})
retryHelper = new RetryHelper(3, 0, 0) retryHelper = new RetryHelper(3, 0, 0)
}) })
@@ -36,6 +20,7 @@ describe('retry-helper tests', () => {
}) })
afterAll(() => { afterAll(() => {
// Restore
jest.restoreAllMocks() jest.restoreAllMocks()
}) })

View File

@@ -1,284 +0,0 @@
import {
jest,
describe,
it,
expect,
beforeAll,
afterEach,
afterAll
} from '@jest/globals'
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'
const FORK_QUALIFIED_REPO = 'another-repo/fork'
// Mutable mock context
const mockContext: any = {
eventName: '',
payload: {},
repo: {owner: 'some-owner', repo: 'some-repo'},
ref: '',
sha: ''
}
jest.unstable_mockModule('@actions/github', () => ({
context: mockContext
}))
// Dynamic imports after mocking
const {assertSafePrCheckout} =
await import('../src/unsafe-pr-checkout-helper.js')
const originalEventName = mockContext.eventName
const originalPayload = mockContext.payload
function setContext(eventName: string, payload: object): void {
mockContext.eventName = eventName
mockContext.payload = payload
}
function forkPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
},
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, full_name: BASE_QUALIFIED_REPO}
},
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, full_name: FORK_QUALIFIED_REPO}
}
}
}
describe('unsafe-pr-checkout-helper', () => {
beforeAll(() => {
mockContext.repo = {owner: 'some-owner', repo: 'some-repo'}
})
afterEach(() => {
mockContext.eventName = originalEventName
mockContext.payload = originalPayload
})
afterAll(() => {
mockContext.eventName = originalEventName
mockContext.payload = originalPayload
})
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: FORK_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target checkout of an unrelated third-party repo', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'some-other/unrelated',
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target ignoring repository case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: FORK_QUALIFIED_REPO.toUpperCase(),
ref: '',
commit: '',
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()
})
})

View File

@@ -1,5 +1,4 @@
import {jest, describe, it, expect, beforeEach, afterAll} from '@jest/globals' import * as urlHelper from '../src/url-helper'
import * as urlHelper from '../src/url-helper.js'
describe('getServerUrl tests', () => { describe('getServerUrl tests', () => {
it('basics', async () => { it('basics', async () => {

View File

@@ -98,15 +98,12 @@ inputs:
github-server-url: 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 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 required: false
allow-unsafe-pr-checkout: reference-cache:
description: > description: >
Required to check out fork pull request code from a workflow triggered by Path to a local directory used as a reference cache for Git clones. Over time,
`pull_request_target` or `workflow_run`. These workflows run with the this directory will contain bare clones of the checked-out repositories (and their submodules).
base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and Using this significantly reduces network bandwidth and speeds up clones.
runner access; fetching and executing a fork's code in that trusted required: false
context commonly leads to "pwn request" vulnerabilities. Set to `true`
only after reviewing the risks at https://gh.io/securely-using-pull_request_target.
default: false
outputs: outputs:
ref: ref:
description: 'The branch, tag or SHA that was checked out' description: 'The branch, tag or SHA that was checked out'

View File

@@ -0,0 +1,37 @@
# Reference Cache für schnelle Checkouts
## Zusammenfassung
Einführung eines lokal verwalteten Git-Referenz-Caches für Haupt-Repositories und Submodule, um Netzwerk-Traffic und Checkout-Zeiten auf persistenten Runnern (z.B. Self-Hosted) massiv zu reduzieren.
## Implementierungsplan
1. **Inputs:**
- In `action.yml` einen neuen Input `reference-cache` (Pfad zum Cache-Verzeichnis) hinzufügen. Default ist leer.
- In `src/git-source-settings.ts` und `src/input-helper.ts` den Input auslesen und bereitstellen (`settings.referenceCache`).
2. **Cache Manager (`src/git-cache-helper.ts`):**
- Eine neue Klasse/Helper-Logik, die das Erstellen (`git clone --bare`) und Aktualisieren (`git fetch --force`) von Bare Cache-Repos übernimmt.
- **Namenskonvention Cache-Verzeichnis:** Damit Admin-Lesbarkeit und Kollisionsfreiheit gewährleistet sind, wird das Cache-Verzeichnis aus der Repository-URL gebildet:
- Alle Sonderzeichen in der URL durch `_` ersetzen.
- Ein kurzer Hash (z. B. erste 8 Zeichen des SHA256) der echten URL zur Eindeutigkeit anhängen.
- Beispiel: `<reference-cache>/https___github_com_actions_checkout_8f9b1c2a.git`
3. **Haupt-Repo Checkout (`src/git-source-provider.ts`):**
- Vor dem Setup des Checkouts prüfen, ob `reference-cache` gesetzt ist.
- Wenn ja: den Cache-Ordner für die Haupt-URL aktualisieren/anlegen.
- Nach dem initialen `git.init()` den Pfad in `.git/objects/info/alternates` schreiben, der auf das `objects`-Verzeichnis des Cache-Ordners zeigt.
4. **Submodule Checkouts (Iterativ statt monolithisch):**
- Der aktuelle Befehl `git submodule update --recursive` funktioniert nicht out-of-the-box mit `reference`, wenn jedes Submodul seinen individuellen Referenz-Cache benötigt.
- Wenn `reference-cache` aktiv ist und Submodule initialisiert werden sollen:
- Lese `.gitmodules` aus (alle Sub-URLs ermitteln).
- Für jedes Submodul den Cache (genauso wie in Step 2) anlegen oder aktualisieren.
- Submodul einzeln auschecken per `git submodule update --init --reference <cache-pfad/.git> <pfad>`.
- Bei der Einstellung `recursive`: In jedes Submodul-Verzeichnis wechseln und den Vorgang für `.gitmodules` rekursiv auf Skript-Ebene durchführen (anstatt Git's `--recursive` Flag einfach weiterzugeben).
## Akzeptanzkriterien
1. **Neue Option konfigurierbar**: Der Input `reference-cache` kann übergeben werden, der Code reagiert darauf.
2. **Ordnerstruktur korrekt**: Der Cache-Ordner für das Hauptrepo und Submodule erhält Namen nach der "URL_Sonderzeichen_Ersetzt+SHA_Cut"-Logik.
3. **Bandbreite gespart / Alternates genutzt**: Beim Hauptcheckout wird eine `.git/objects/info/alternates`-Datei mit Pfad zum lokalen Cache erzeugt. Danach ausgeführte `git fetch`-Befehle sind signifikant schneller bzw. laden deutlich weniger Bytes herunter.
4. **Submodule erhalten Caches**: Auch tiefe (rekursive) Submodule profitieren für deren jeweilige Remote-URL vom Cache, da pro Submodul ein passender `--reference` Punkt dynamisch berechnet und übergeben wird.
5. **Kein --dissociate**: Aus Performance-Gründen bleibt der Arbeitsordner an den Cache gebunden (`git repack` ist zeitaufwändig). Fällt der Cache weg, muss der Workspace erst einmal neu erzeugt werden (was bei Action Runnern die Norm ist, falls es nicht ohnehin "single-use" Runner sind).

55334
dist/index.js vendored

File diff suppressed because one or more lines are too long

3
dist/package.json vendored
View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

12
jest.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
clearMocks: true,
fakeTimers: {},
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

View File

@@ -1,24 +0,0 @@
export default {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
roots: ['<rootDir>'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
useESM: true,
diagnostics: {
ignoreCodes: [151002]
}
}
]
},
extensionsToTreatAsEsm: ['.ts'],
transformIgnorePatterns: ['node_modules/(?!(@actions)/)'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
verbose: true
}

845
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,14 @@
{ {
"name": "checkout", "name": "checkout",
"version": "7.0.0", "version": "5.0.0",
"description": "checkout action", "description": "checkout action",
"type": "module",
"main": "lib/main.js", "main": "lib/main.js",
"scripts": { "scripts": {
"build": "tsc && ncc build src/main.ts -o dist && node lib/misc/generate-docs.js", "build": "tsc && ncc build && node lib/misc/generate-docs.js",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "jest",
"licensed-check": "src/misc/licensed-check.sh", "licensed-check": "src/misc/licensed-check.sh",
"licensed-generate": "src/misc/licensed-generate.sh" "licensed-generate": "src/misc/licensed-generate.sh"
}, },
@@ -28,30 +27,31 @@
"url": "https://github.com/actions/checkout/issues" "url": "https://github.com/actions/checkout/issues"
}, },
"homepage": "https://github.com/actions/checkout#readme", "homepage": "https://github.com/actions/checkout#readme",
"engines": {
"node": ">=24"
},
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1", "@actions/core": "^1.10.1",
"@actions/exec": "^3.0.0", "@actions/exec": "^1.1.1",
"@actions/github": "^9.1.1", "@actions/github": "^6.0.0",
"@actions/io": "^3.0.2", "@actions/io": "^1.1.3",
"@actions/tool-cache": "^4.0.0" "@actions/tool-cache": "^2.0.1",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^24.1.0", "@types/node": "^24.1.0",
"@types/proper-lockfile": "^4.1.4",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0", "@typescript-eslint/parser": "^7.9.0",
"@vercel/ncc": "^0.44.0", "@vercel/ncc": "^0.38.1",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-github": "^4.10.2", "eslint-plugin-github": "^4.10.2",
"eslint-plugin-jest": "^28.8.2", "eslint-plugin-jest": "^28.8.2",
"jest": "^29.7.0", "jest": "^29.7.0",
"js-yaml": "^4.2.0", "jest-circus": "^29.7.0",
"prettier": "^3.8.4", "js-yaml": "^4.1.0",
"ts-jest": "^29.4.11", "prettier": "^3.3.3",
"ts-node": "^10.9.2", "proper-lockfile": "^4.1.2",
"ts-jest": "^29.2.5",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View File

@@ -5,12 +5,12 @@ import * as fs from 'fs'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as os from 'os' import * as os from 'os'
import * as path from 'path' import * as path from 'path'
import * as regexpHelper from './regexp-helper.js' import * as regexpHelper from './regexp-helper'
import * as stateHelper from './state-helper.js' import * as stateHelper from './state-helper'
import * as urlHelper from './url-helper.js' import * as urlHelper from './url-helper'
import {randomUUID} from 'crypto' import {v4 as uuid} from 'uuid'
import {IGitCommandManager} from './git-command-manager.js' import {IGitCommandManager} from './git-command-manager'
import {IGitSourceSettings} from './git-source-settings.js' import {IGitSourceSettings} from './git-source-settings'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
const SSH_COMMAND_KEY = 'core.sshCommand' const SSH_COMMAND_KEY = 'core.sshCommand'
@@ -21,6 +21,7 @@ export interface IGitAuthHelper {
configureSubmoduleAuth(): Promise<void> configureSubmoduleAuth(): Promise<void>
configureTempGlobalConfig(): Promise<string> configureTempGlobalConfig(): Promise<string>
removeAuth(): Promise<void> removeAuth(): Promise<void>
removeGlobalAuth(): Promise<void>
removeGlobalConfig(): Promise<void> removeGlobalConfig(): Promise<void>
} }
@@ -90,7 +91,7 @@ class GitAuthHelper {
// Create a temp home directory // Create a temp home directory
const runnerTemp = process.env['RUNNER_TEMP'] || '' const runnerTemp = process.env['RUNNER_TEMP'] || ''
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
const uniqueId = randomUUID() const uniqueId = uuid()
this.temporaryHomePath = path.join(runnerTemp, uniqueId) this.temporaryHomePath = path.join(runnerTemp, uniqueId)
await fs.promises.mkdir(this.temporaryHomePath, {recursive: true}) await fs.promises.mkdir(this.temporaryHomePath, {recursive: true})
@@ -235,6 +236,12 @@ class GitAuthHelper {
await this.removeToken() await this.removeToken()
} }
async removeGlobalAuth(): Promise<void> {
core.debug('Removing global auth entries')
await this.git.tryConfigUnset('include.path', true)
await this.git.tryConfigUnset(this.insteadOfKey, true)
}
async removeGlobalConfig(): Promise<void> { async removeGlobalConfig(): Promise<void> {
if (this.temporaryHomePath?.length > 0) { if (this.temporaryHomePath?.length > 0) {
core.debug(`Unsetting HOME override`) core.debug(`Unsetting HOME override`)
@@ -255,7 +262,7 @@ class GitAuthHelper {
// Write key // Write key
const runnerTemp = process.env['RUNNER_TEMP'] || '' const runnerTemp = process.env['RUNNER_TEMP'] || ''
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
const uniqueId = randomUUID() const uniqueId = uuid()
this.sshKeyPath = path.join(runnerTemp, uniqueId) this.sshKeyPath = path.join(runnerTemp, uniqueId)
stateHelper.setSshKeyPath(this.sshKeyPath) stateHelper.setSshKeyPath(this.sshKeyPath)
await fs.promises.mkdir(runnerTemp, {recursive: true}) await fs.promises.mkdir(runnerTemp, {recursive: true})
@@ -422,7 +429,7 @@ class GitAuthHelper {
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
// Create a unique filename for this checkout instance // Create a unique filename for this checkout instance
const configFileName = `git-credentials-${randomUUID()}.config` const configFileName = `git-credentials-${uuid()}.config`
this.credentialsConfigPath = path.join(runnerTemp, configFileName) this.credentialsConfigPath = path.join(runnerTemp, configFileName)
core.debug(`Credentials config path: ${this.credentialsConfigPath}`) core.debug(`Credentials config path: ${this.credentialsConfigPath}`)

98
src/git-cache-helper.ts Normal file
View File

@@ -0,0 +1,98 @@
import * as core from '@actions/core'
import * as path from 'path'
import * as fs from 'fs'
import * as crypto from 'crypto'
import * as lockfile from 'proper-lockfile'
import {IGitCommandManager} from './git-command-manager'
export class GitCacheHelper {
constructor(private referenceCache: string) {}
/**
* Prepares the reference cache for a given repository URL.
* If the cache does not exist, it performs a bare clone.
* If it exists, it performs a fetch to update it.
* Returns the absolute path to the bare cache repository.
*/
async setupCache(git: IGitCommandManager, repositoryUrl: string): Promise<string> {
const cacheDirName = this.generateCacheDirName(repositoryUrl)
const cachePath = path.join(this.referenceCache, cacheDirName)
// Ensure the base cache directory exists before we try to lock inside it
if (!fs.existsSync(this.referenceCache)) {
await fs.promises.mkdir(this.referenceCache, { recursive: true })
}
// We use a dedicated lock dir specifically for this repository's cache
// since we cannot place a lock *inside* a repository that might not exist yet
const lockfilePath = `${cachePath}.lock`
// Ensure the file we are locking exists
if (!fs.existsSync(lockfilePath)) {
await fs.promises.writeFile(lockfilePath, '')
}
core.debug(`Acquiring lock for ${repositoryUrl} at ${lockfilePath}`)
let releaseLock: () => Promise<void>
try {
// proper-lockfile creates a ".lock" directory next to the target file.
// We configure it to wait up to 10 minutes (600,000 ms) for another process to finish.
// E.g. cloning a very large monorepo might take minutes.
releaseLock = await lockfile.lock(lockfilePath, {
retries: {
retries: 60, // try 60 times
factor: 1, // linear backoff
minTimeout: 10000, // wait 10 seconds between tries
maxTimeout: 10000, // (total max wait time: 600s = 10m)
randomize: true
}
})
core.debug(`Lock acquired.`)
} catch (err) {
throw new Error(`Failed to acquire lock for repository cache ${repositoryUrl}: ${err}`)
}
try {
if (fs.existsSync(path.join(cachePath, 'objects'))) {
core.info(`Reference cache for ${repositoryUrl} exists. Updating...`)
const args = ['-C', cachePath, 'fetch', '--force', '--prune', '--tags', 'origin', '+refs/heads/*:refs/heads/*']
await git.execGit(args)
} else {
core.info(`Reference cache for ${repositoryUrl} does not exist. Cloning --bare...`)
// Use a temporary clone pattern to prevent corrupted repos if process is killed mid-clone
const tmpPath = `${cachePath}.tmp.${crypto.randomUUID()}`
try {
const args = ['-C', this.referenceCache, 'clone', '--bare', repositoryUrl, tmpPath]
await git.execGit(args)
if (fs.existsSync(cachePath)) {
// In rare cases where it somehow exists but objects/ didn't, clean it up
await fs.promises.rm(cachePath, { recursive: true, force: true })
}
await fs.promises.rename(tmpPath, cachePath)
} catch (cloneErr) {
// Cleanup partial clone if an error occurred
await fs.promises.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
throw cloneErr
}
}
} finally {
await releaseLock()
}
return cachePath
}
/**
* Generates a directory name for the cache based on the URL.
* Replaces non-alphanumeric characters with underscores
* and appends a short SHA256 hash of the original URL.
*/
generateCacheDirName(url: string): string {
const cleanUrl = url.replace(/[^a-zA-Z0-9]/g, '_')
const hash = crypto.createHash('sha256').update(url).digest('hex').substring(0, 8)
return `${cleanUrl}_${hash}.git`
}
}

View File

@@ -1,13 +1,13 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as exec from '@actions/exec' import * as exec from '@actions/exec'
import * as fs from 'fs' import * as fs from 'fs'
import * as fshelper from './fs-helper.js' import * as fshelper from './fs-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as path from 'path' import * as path from 'path'
import * as refHelper from './ref-helper.js' import * as refHelper from './ref-helper'
import * as regexpHelper from './regexp-helper.js' import * as regexpHelper from './regexp-helper'
import * as retryHelper from './retry-helper.js' import * as retryHelper from './retry-helper'
import {GitVersion} from './git-version.js' import {GitVersion} from './git-version'
// Auth header not supported before 2.9 // Auth header not supported before 2.9
// Wire protocol v2 not supported before 2.18 // Wire protocol v2 not supported before 2.18
@@ -15,6 +15,11 @@ import {GitVersion} from './git-version.js'
export const MinimumGitVersion = new GitVersion('2.18') export const MinimumGitVersion = new GitVersion('2.18')
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28') export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')
export class GitOutput {
stdout = ''
exitCode = 0
}
export interface IGitCommandManager { export interface IGitCommandManager {
branchDelete(remote: boolean, branch: string): Promise<void> branchDelete(remote: boolean, branch: string): Promise<void>
branchExists(remote: boolean, pattern: string): Promise<boolean> branchExists(remote: boolean, pattern: string): Promise<boolean>
@@ -43,11 +48,12 @@ export interface IGitCommandManager {
getDefaultBranch(repositoryUrl: string): Promise<string> getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string getWorkingDirectory(): string
init(objectFormat?: string): Promise<void> init(): Promise<void>
isDetached(): Promise<boolean> isDetached(): Promise<boolean>
lfsFetch(ref: string): Promise<void> lfsFetch(ref: string): Promise<void>
lfsInstall(): Promise<void> lfsInstall(): Promise<void>
log1(format?: string): Promise<string> log1(format?: string): Promise<string>
referenceAdd(referenceObjects: string): Promise<void>
remoteAdd(remoteName: string, remoteUrl: string): Promise<void> remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
removeEnvironmentVariable(name: string): void removeEnvironmentVariable(name: string): void
revParse(ref: string): Promise<string> revParse(ref: string): Promise<string>
@@ -80,6 +86,12 @@ export interface IGitCommandManager {
): Promise<string[]> ): Promise<string[]>
tryReset(): Promise<boolean> tryReset(): Promise<boolean>
version(): Promise<GitVersion> version(): Promise<GitVersion>
execGit(
args: string[],
allowAllExitCodes?: boolean,
silent?: boolean,
customListeners?: any
): Promise<GitOutput>
} }
export async function createCommandManager( export async function createCommandManager(
@@ -364,14 +376,8 @@ class GitCommandManager {
return this.workingDirectory return this.workingDirectory
} }
async init(objectFormat?: string): Promise<void> { async init(): Promise<void> {
const args = ['init'] await this.execGit(['init', this.workingDirectory])
if (objectFormat === 'sha256') {
args.push('--object-format=sha256')
}
args.push(this.workingDirectory)
await this.execGit(args)
} }
async isDetached(): Promise<boolean> { async isDetached(): Promise<boolean> {
@@ -407,6 +413,32 @@ class GitCommandManager {
await this.execGit(['remote', 'add', remoteName, remoteUrl]) await this.execGit(['remote', 'add', remoteName, remoteUrl])
} }
async referenceAdd(referenceObjects: string): Promise<void> {
const alternatesPath = path.join(
this.workingDirectory,
'.git',
'objects',
'info',
'alternates'
)
core.info(`Configuring git alternate to reference objects at ${referenceObjects}`)
const infoDir = path.dirname(alternatesPath)
if (!fs.existsSync(infoDir)) {
await fs.promises.mkdir(infoDir, { recursive: true })
}
let existing = ''
if (fs.existsSync(alternatesPath)) {
existing = (await fs.promises.readFile(alternatesPath, 'utf8')).trim()
}
const lines = existing ? existing.split('\n') : []
if (!lines.includes(referenceObjects)) {
lines.push(referenceObjects)
await fs.promises.writeFile(alternatesPath, lines.join('\n') + '\n')
}
}
removeEnvironmentVariable(name: string): void { removeEnvironmentVariable(name: string): void {
delete this.gitEnv[name] delete this.gitEnv[name]
} }
@@ -615,7 +647,7 @@ class GitCommandManager {
return result return result
} }
private async execGit( async execGit(
args: string[], args: string[],
allowAllExitCodes = false, allowAllExitCodes = false,
silent = false, silent = false,
@@ -752,7 +784,3 @@ class GitCommandManager {
} }
} }
class GitOutput {
stdout = ''
exitCode = 0
}

View File

@@ -1,10 +1,10 @@
import * as assert from 'assert' import * as assert from 'assert'
import * as core from '@actions/core' import * as core from '@actions/core'
import * as fs from 'fs' import * as fs from 'fs'
import * as fsHelper from './fs-helper.js' import * as fsHelper from './fs-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as path from 'path' import * as path from 'path'
import {IGitCommandManager} from './git-command-manager.js' import {IGitCommandManager} from './git-command-manager'
export async function prepareExistingDirectory( export async function prepareExistingDirectory(
git: IGitCommandManager | undefined, git: IGitCommandManager | undefined,

View File

@@ -1,19 +1,169 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as fsHelper from './fs-helper.js' import * as fsHelper from './fs-helper'
import * as gitAuthHelper from './git-auth-helper.js' import * as gitAuthHelper from './git-auth-helper'
import * as gitCommandManager from './git-command-manager.js' import * as gitCommandManager from './git-command-manager'
import * as gitDirectoryHelper from './git-directory-helper.js' import * as gitDirectoryHelper from './git-directory-helper'
import * as githubApiHelper from './github-api-helper.js' import * as githubApiHelper from './github-api-helper'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as path from 'path' import * as path from 'path'
import * as refHelper from './ref-helper.js' import * as refHelper from './ref-helper'
import * as stateHelper from './state-helper.js' import * as stateHelper from './state-helper'
import * as urlHelper from './url-helper.js' import * as urlHelper from './url-helper'
import { import {
MinimumGitSparseCheckoutVersion, MinimumGitSparseCheckoutVersion,
IGitCommandManager IGitCommandManager
} from './git-command-manager.js' } from './git-command-manager'
import {IGitSourceSettings} from './git-source-settings.js' import {IGitSourceSettings} from './git-source-settings'
import {GitCacheHelper} from './git-cache-helper'
import * as fs from 'fs'
interface SubmoduleInfo {
name: string
path: string
url: string
}
async function iterativeSubmoduleUpdate(
git: IGitCommandManager,
cacheHelper: GitCacheHelper,
repositoryPath: string,
fetchDepth: number,
nestedSubmodules: boolean
): Promise<void> {
const gitmodulesPath = path.join(repositoryPath, '.gitmodules')
if (!fs.existsSync(gitmodulesPath)) {
return
}
const submodules = new Map<string, SubmoduleInfo>()
// Get all submodule config keys
try {
const output = await git.execGit([
'-C', repositoryPath,
'config', '--file', gitmodulesPath, '--get-regexp', 'submodule\\..*'
], true, true)
const lines = output.stdout.split('\n').filter(l => l.trim().length > 0)
for (const line of lines) {
const match = line.match(/^submodule\.(.+?)\.(path|url)\s+(.*)$/)
if (match) {
const [, name, key, value] = match
if (!submodules.has(name)) {
submodules.set(name, { name, path: '', url: '' })
}
const info = submodules.get(name)!
if (key === 'path') info.path = value
if (key === 'url') info.url = value
}
}
} catch (err) {
core.warning(`Failed to read .gitmodules: ${err}`)
return
}
for (const info of submodules.values()) {
if (!info.path || !info.url) continue
core.info(`Processing submodule ${info.name} at ${info.path}`)
// Resolve relative URLs or valid URLs
let subUrl = info.url
if (subUrl.startsWith('../') || subUrl.startsWith('./')) {
// In checkout action, relative URLs are handled automatically by git.
// But for our bare cache clone, we need an absolute URL.
let originUrl = ''
try {
const originOut = await git.execGit(['-C', repositoryPath, 'remote', 'get-url', 'origin'], true, true)
if (originOut.exitCode === 0) {
originUrl = originOut.stdout.trim()
}
if (originUrl) {
try {
if (originUrl.match(/^https?:\/\//)) {
// Using Node's URL class to resolve relative paths for HTTP(s)
const parsedOrigin = new URL(originUrl.replace(/\.git$/, ''))
const resolvedUrl = new URL(subUrl, parsedOrigin.href + '/')
subUrl = resolvedUrl.href
} else {
// Fallback for SSH URLs which new URL() cannot parse (e.g. git@github.com:org/repo)
let originParts = originUrl.replace(/\.git$/, '').split('/')
originParts.pop() // remove current repo
// Handle multiple ../
let subTarget = subUrl
while (subTarget.startsWith('../')) {
if (originParts.length === 0) break // Can't go higher
originParts.pop()
subTarget = subTarget.substring(3)
}
if (subTarget.startsWith('./')) {
subTarget = subTarget.substring(2)
}
if (originParts.length > 0) {
subUrl = originParts.join('/') + '/' + subTarget
}
}
} catch {
// Fallback does not work
}
}
} catch {
// ignore
}
}
if (!subUrl || subUrl.startsWith('../') || subUrl.startsWith('./')) {
core.warning(`Could not resolve absolute URL for submodule ${info.name}. Falling back to standard clone.`)
await invokeStandardSubmoduleUpdate(git, repositoryPath, fetchDepth, info.path)
continue
}
try {
// Prepare cache
const cachePath = await cacheHelper.setupCache(git, subUrl)
// Submodule update for this specific one
const args = ['-C', repositoryPath, '-c', 'protocol.version=2', 'submodule', 'update', '--init', '--force']
if (fetchDepth > 0) {
args.push(`--depth=${fetchDepth}`)
}
args.push('--reference', cachePath)
args.push(info.path)
const output = await git.execGit(args, true)
if (output.exitCode !== 0) {
throw new Error(`Submodule update failed with exit code ${output.exitCode}`)
}
} catch (err) {
core.warning(`Reference cache failed for submodule ${info.name} (${err}). Falling back to standard clone...`)
await invokeStandardSubmoduleUpdate(git, repositoryPath, fetchDepth, info.path)
}
// Recursive update inside the submodule
if (nestedSubmodules) {
const subRepoPath = path.join(repositoryPath, info.path)
await iterativeSubmoduleUpdate(
git,
cacheHelper,
subRepoPath,
fetchDepth,
nestedSubmodules
)
}
}
}
async function invokeStandardSubmoduleUpdate(git: IGitCommandManager, repositoryPath: string, fetchDepth: number, submodulePath: string) {
const args = ['-C', repositoryPath, '-c', 'protocol.version=2', 'submodule', 'update', '--init', '--force']
if (fetchDepth > 0) {
args.push(`--depth=${fetchDepth}`)
}
args.push(submodulePath)
await git.execGit(args)
}
export async function getSource(settings: IGitSourceSettings): Promise<void> { export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Repository URL // Repository URL
@@ -105,33 +255,56 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Save state for POST action // Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath) stateHelper.setRepositoryPath(settings.repositoryPath)
// If we didn't initialize it above, do it now
if (!authHelper) {
authHelper = gitAuthHelper.createAuthHelper(git, settings)
}
// Check if we need global auth setup early for reference cache
// Global auth does not require a local .git directory
if (settings.referenceCache) {
core.startGroup('Setting up global auth for reference cache')
await authHelper.configureGlobalAuth()
core.endGroup()
}
// Initialize the repository // Initialize the repository
if ( if (
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) !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') core.startGroup('Initializing the repository')
await git.init(objectFormat) await git.init()
await git.remoteAdd('origin', repositoryUrl) await git.remoteAdd('origin', repositoryUrl)
core.endGroup() core.endGroup()
// Setup reference cache if requested
if (settings.referenceCache) {
core.startGroup('Setting up reference repository cache')
const cacheHelper = new GitCacheHelper(settings.referenceCache)
const cachePath = await cacheHelper.setupCache(git, repositoryUrl)
const cacheObjects = path.join(cachePath, 'objects')
if (fsHelper.directoryExistsSync(cacheObjects, false)) {
await git.referenceAdd(cacheObjects)
} else {
core.warning(`Reference repository cache objects directory ${cacheObjects} does not exist`)
}
core.endGroup()
}
} }
// Remove global auth if it was set for reference cache,
// to avoid duplicate AUTHORIZATION headers during fetch
if (settings.referenceCache) {
core.startGroup('Removing global auth after reference cache setup')
await authHelper.removeGlobalAuth()
core.endGroup()
}
// Configure auth (must happen after git init so .git exists)
core.startGroup('Setting up auth')
await authHelper.configureAuth()
core.endGroup()
// Disable automatic garbage collection // Disable automatic garbage collection
core.startGroup('Disabling automatic garbage collection') core.startGroup('Disabling automatic garbage collection')
if (!(await git.tryDisableAutomaticGarbageCollection())) { if (!(await git.tryDisableAutomaticGarbageCollection())) {
@@ -141,15 +314,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
} }
core.endGroup() core.endGroup()
// If we didn't initialize it above, do it now
if (!authHelper) {
authHelper = gitAuthHelper.createAuthHelper(git, settings)
}
// Configure auth
core.startGroup('Setting up auth')
await authHelper.configureAuth()
core.endGroup()
// Determine the default branch // Determine the default branch
if (!settings.ref && !settings.commit) { if (!settings.ref && !settings.commit) {
core.startGroup('Determining the default branch') core.startGroup('Determining the default branch')
@@ -171,6 +335,10 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
await git.lfsInstall() await git.lfsInstall()
} }
// When using reference cache, fetch-depth > 0 is counterproductive:
// objects are served from the local cache, so shallow negotiation only adds latency.
adjustFetchDepthForCache(settings)
// Fetch // Fetch
core.startGroup('Fetching the repository') core.startGroup('Fetching the repository')
const fetchOptions: { const fetchOptions: {
@@ -281,7 +449,21 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Checkout submodules // Checkout submodules
core.startGroup('Fetching submodules') core.startGroup('Fetching submodules')
await git.submoduleSync(settings.nestedSubmodules) await git.submoduleSync(settings.nestedSubmodules)
await git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules)
if (settings.referenceCache) {
core.info('Iterative submodule update using reference cache')
const cacheHelper = new GitCacheHelper(settings.referenceCache)
await iterativeSubmoduleUpdate(
git,
cacheHelper,
settings.repositoryPath,
settings.fetchDepth,
settings.nestedSubmodules
)
} else {
await git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules)
}
await git.submoduleForeach( await git.submoduleForeach(
'git config --local gc.auto 0', 'git config --local gc.auto 0',
settings.nestedSubmodules settings.nestedSubmodules
@@ -390,3 +572,30 @@ async function getGitCommandManager(
return undefined return undefined
} }
} }
/**
* Adjusts fetchDepth when reference-cache is active.
* Shallow fetches are counterproductive with a local cache because
* objects are served from disk, making shallow negotiation pure overhead.
*/
export function adjustFetchDepthForCache(
settings: Pick<
IGitSourceSettings,
'referenceCache' | 'fetchDepth' | 'fetchDepthExplicit'
>
): void {
if (settings.referenceCache && settings.fetchDepth > 0) {
if (settings.fetchDepthExplicit) {
core.warning(
`'fetch-depth: ${settings.fetchDepth}' is set with reference-cache enabled. ` +
`This may slow down checkout because shallow negotiation bypasses the local cache. ` +
`Consider using 'fetch-depth: 0' for best performance with reference-cache.`
)
} else {
core.info(
`Overriding fetch-depth from ${settings.fetchDepth} to 0 because reference-cache is enabled`
)
settings.fetchDepth = 0
}
}
}

View File

@@ -49,6 +49,11 @@ export interface IGitSourceSettings {
*/ */
fetchDepth: number fetchDepth: number
/**
* Whether fetch-depth was explicitly set by the user
*/
fetchDepthExplicit: boolean
/** /**
* Fetch tags, even if fetchDepth > 0 (default: false) * Fetch tags, even if fetchDepth > 0 (default: false)
*/ */
@@ -59,6 +64,11 @@ export interface IGitSourceSettings {
*/ */
showProgress: boolean showProgress: boolean
/**
* The path to a local directory used as a reference cache for Git clones
*/
referenceCache: string
/** /**
* Indicates whether to fetch LFS objects * Indicates whether to fetch LFS objects
*/ */
@@ -118,10 +128,4 @@ export interface IGitSourceSettings {
* User override on the GitHub Server/Host URL that hosts the repository to be cloned * User override on the GitHub Server/Host URL that hosts the repository to be cloned
*/ */
githubServerUrl: string | undefined 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
} }

View File

@@ -4,18 +4,13 @@ import * as fs from 'fs'
import * as github from '@actions/github' import * as github from '@actions/github'
import * as io from '@actions/io' import * as io from '@actions/io'
import * as path from 'path' import * as path from 'path'
import * as retryHelper from './retry-helper.js' import * as retryHelper from './retry-helper'
import * as toolCache from '@actions/tool-cache' import * as toolCache from '@actions/tool-cache'
import {randomUUID} from 'crypto' import {v4 as uuid} from 'uuid'
import {getServerApiUrl} from './url-helper.js' import {getServerApiUrl} from './url-helper'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
export interface RepositoryObjectFormatResult {
format: string
succeeded: boolean
}
export async function downloadRepository( export async function downloadRepository(
authToken: string, authToken: string,
owner: string, owner: string,
@@ -39,7 +34,7 @@ export async function downloadRepository(
// Write archive to disk // Write archive to disk
core.info('Writing archive to disk') core.info('Writing archive to disk')
const uniqueId = randomUUID() const uniqueId = uuid()
const archivePath = IS_WINDOWS const archivePath = IS_WINDOWS
? path.join(repositoryPath, `${uniqueId}.zip`) ? path.join(repositoryPath, `${uniqueId}.zip`)
: path.join(repositoryPath, `${uniqueId}.tar.gz`) : path.join(repositoryPath, `${uniqueId}.tar.gz`)
@@ -127,53 +122,6 @@ 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( async function downloadArchive(
authToken: string, authToken: string,
owner: string, owner: string,

View File

@@ -1,10 +1,9 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as fsHelper from './fs-helper.js' import * as fsHelper from './fs-helper'
import * as github from '@actions/github' import * as github from '@actions/github'
import * as path from 'path' import * as path from 'path'
import * as unsafePrCheckoutHelper from './unsafe-pr-checkout-helper.js' import * as workflowContextHelper from './workflow-context-helper'
import * as workflowContextHelper from './workflow-context-helper.js' import {IGitSourceSettings} from './git-source-settings'
import {IGitSourceSettings} from './git-source-settings.js'
export async function getInputs(): Promise<IGitSourceSettings> { export async function getInputs(): Promise<IGitSourceSettings> {
const result = {} as unknown as IGitSourceSettings const result = {} as unknown as IGitSourceSettings
@@ -72,7 +71,7 @@ export async function getInputs(): Promise<IGitSourceSettings> {
} }
} }
// SHA? // 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.commit = result.ref
result.ref = '' result.ref = ''
} }
@@ -103,7 +102,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
'TRUE' 'TRUE'
// Fetch depth // Fetch depth
result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1')) const fetchDepthInput = core.getInput('fetch-depth')
result.fetchDepthExplicit = fetchDepthInput !== ''
result.fetchDepth = Math.floor(Number(fetchDepthInput || '1'))
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) { if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
result.fetchDepth = 0 result.fetchDepth = 0
} }
@@ -162,18 +163,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.githubServerUrl = core.getInput('github-server-url') result.githubServerUrl = core.getInput('github-server-url')
core.debug(`GitHub Host URL = ${result.githubServerUrl}`) core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs) // Reference Cache
result.allowUnsafePrCheckout = result.referenceCache = core.getInput('reference-cache')
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() === core.debug(`Reference Cache = ${result.referenceCache}`)
'TRUE'
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`)
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
})
return result return result
} }

View File

@@ -1,11 +1,9 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import * as gitSourceProvider from './git-source-provider.js' import * as coreCommand from '@actions/core/lib/command'
import * as inputHelper from './input-helper.js' import * as gitSourceProvider from './git-source-provider'
import * as inputHelper from './input-helper'
import * as path from 'path' import * as path from 'path'
import * as stateHelper from './state-helper.js' import * as stateHelper from './state-helper'
import {fileURLToPath} from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
@@ -13,8 +11,10 @@ async function run(): Promise<void> {
try { try {
// Register problem matcher // Register problem matcher
core.info( coreCommand.issueCommand(
`::add-matcher::${path.join(__dirname, 'problem-matcher.json')}` 'add-matcher',
{},
path.join(__dirname, 'problem-matcher.json')
) )
// Get sources // Get sources
@@ -22,7 +22,7 @@ async function run(): Promise<void> {
core.setOutput('ref', sourceSettings.ref) core.setOutput('ref', sourceSettings.ref)
} finally { } finally {
// Unregister problem matcher // Unregister problem matcher
core.info('::remove-matcher owner=checkout-git::') coreCommand.issueCommand('remove-matcher', {owner: 'checkout-git'}, '')
} }
} catch (error) { } catch (error) {
core.setFailed(`${(error as any)?.message ?? error}`) core.setFailed(`${(error as any)?.message ?? error}`)

View File

@@ -2,9 +2,6 @@ import * as fs from 'fs'
import * as os from 'os' import * as os from 'os'
import * as path from 'path' import * as path from 'path'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import {fileURLToPath} from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// //
// SUMMARY // SUMMARY
@@ -123,7 +120,7 @@ function updateUsage(
} }
updateUsage( updateUsage(
'actions/checkout@v7', 'actions/checkout@v6',
path.join(__dirname, '..', '..', 'action.yml'), path.join(__dirname, '..', '..', 'action.yml'),
path.join(__dirname, '..', '..', 'README.md') path.join(__dirname, '..', '..', 'README.md')
) )

View File

@@ -1,7 +1,7 @@
import {IGitCommandManager} from './git-command-manager.js' import {IGitCommandManager} from './git-command-manager'
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import {getServerApiUrl, isGhes} from './url-helper.js' import {getServerApiUrl, isGhes} from './url-helper'
export const tagsRefSpec = '+refs/tags/*:refs/tags/*' export const tagsRefSpec = '+refs/tags/*:refs/tags/*'
@@ -258,9 +258,7 @@ export async function checkCommitInfo(
} }
// Extract details from message // Extract details from message
const match = commitInfo.match( const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/)
/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/
)
if (!match) { if (!match) {
core.debug('Unexpected message format') core.debug('Unexpected message format')
return return
@@ -292,7 +290,7 @@ export async function checkCommitInfo(
} }
} }
export function fromPayload(path: string): any { function fromPayload(path: string): any {
return select(github.context.payload, path) return select(github.context.payload, path)
} }

View File

@@ -1,88 +0,0 @@
import * as github from '@actions/github'
import {fromPayload} from './ref-helper.js'
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/
export interface IUnsafePrCheckoutInput {
qualifiedRepository: string
ref: string
commit: string | undefined
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
let prHeadRepoFullName: unknown
const prShas: string[] = []
if (eventName === 'pull_request_target') {
prHeadRepoId = fromPayload('pull_request.head.repo.id')
prHeadRepoFullName = fromPayload('pull_request.head.repo.full_name')
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')
prHeadRepoFullName = fromPayload('workflow_run.head_repository.full_name')
pushIfSha(prShas, fromPayload('workflow_run.head_commit.id'))
// For `pull_request_target`-triggered workflow_run, `head_sha` is the base
// default branch SHA (not the PR head)
if (wrEvent !== 'pull_request_target') {
pushIfSha(prShas, fromPayload('workflow_run.head_sha'))
}
}
// (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 repositoryMatchesPrHead =
typeof prHeadRepoFullName === 'string' &&
input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase()
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref)
const commitMatchesPrHeadSha =
!!input.commit && prShas.includes(input.commit.toLowerCase())
if (
!repositoryMatchesPrHead &&
!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 and executing a fork's code in that trusted ` +
`context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks ` +
`at https://gh.io/securely-using-pull_request_target and 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())
}
}

View File

@@ -1,6 +1,6 @@
import * as assert from 'assert' import * as assert from 'assert'
import {URL} from 'url' import {URL} from 'url'
import {IGitSourceSettings} from './git-source-settings.js' import {IGitSourceSettings} from './git-source-settings'
export function getFetchUrl(settings: IGitSourceSettings): string { export function getFetchUrl(settings: IGitSourceSettings): string {
assert.ok( assert.ok(

View File

@@ -1,13 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "es6",
"module": "NodeNext", "module": "commonjs",
"moduleResolution": "NodeNext", "lib": [
"es6"
],
"outDir": "./lib", "outDir": "./lib",
"rootDir": "./src", "rootDir": "./src",
"declaration": true,
"strict": true, "strict": true,
"noImplicitAny": false, "noImplicitAny": false,
"esModuleInterop": true "esModuleInterop": true,
"skipLibCheck": true
}, },
"exclude": ["__test__", "lib", "node_modules", "jest.config.ts"] "exclude": ["__test__", "lib", "node_modules"]
} }