Compare commits
No commits in common. "test-data/v2/side-by-side-1" and "master" have entirely different histories.
test-data/
...
master
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
lib/
|
||||
node_modules/
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"plugins": ["jest", "@typescript-eslint"],
|
||||
"extends": ["plugin:github/es6"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"eslint-comments/no-use": "off",
|
||||
"import/no-namespace": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
|
||||
"@typescript-eslint/no-require-imports": "error",
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/ban-ts-ignore": "error",
|
||||
"camelcase": "off",
|
||||
"@typescript-eslint/camelcase": "error",
|
||||
"@typescript-eslint/class-name-casing": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
|
||||
"@typescript-eslint/func-call-spacing": ["error", "never"],
|
||||
"@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"],
|
||||
"@typescript-eslint/no-array-constructor": "error",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-extraneous-class": "error",
|
||||
"@typescript-eslint/no-for-in-array": "error",
|
||||
"@typescript-eslint/no-inferrable-types": "error",
|
||||
"@typescript-eslint/no-misused-new": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-object-literal-type-assertion": "error",
|
||||
"@typescript-eslint/no-unnecessary-qualifier": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-var-requires": "error",
|
||||
"@typescript-eslint/prefer-for-of": "warn",
|
||||
"@typescript-eslint/prefer-function-type": "warn",
|
||||
"@typescript-eslint/prefer-includes": "error",
|
||||
"@typescript-eslint/prefer-interface": "error",
|
||||
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/require-array-sort-compare": "error",
|
||||
"@typescript-eslint/restrict-plus-operands": "error",
|
||||
"semi": "off",
|
||||
"@typescript-eslint/semi": ["error", "never"],
|
||||
"@typescript-eslint/type-annotation-spacing": "error",
|
||||
"@typescript-eslint/unbound-method": "error"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"jest/globals": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
name: Build and Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- releases/*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- uses: actions/checkout@v2
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run format-check
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- name: Verify no unstaged changes
|
||||
run: __test__/verify-no-unstaged-changes.sh
|
||||
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
runs-on: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
|
||||
steps:
|
||||
# Clone this repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Basic checkout
|
||||
- name: Checkout basic
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
shell: bash
|
||||
run: __test__/verify-basic.sh
|
||||
|
||||
# Clean
|
||||
- name: Modify work tree
|
||||
shell: bash
|
||||
run: __test__/modify-work-tree.sh
|
||||
- name: Checkout clean
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify clean
|
||||
shell: bash
|
||||
run: __test__/verify-clean.sh
|
||||
|
||||
# Side by side
|
||||
- name: Checkout side by side 1
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/side-by-side-1
|
||||
path: side-by-side-1
|
||||
- name: Checkout side by side 2
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/side-by-side-2
|
||||
path: side-by-side-2
|
||||
- name: Verify side by side
|
||||
shell: bash
|
||||
run: __test__/verify-side-by-side.sh
|
||||
|
||||
# LFS
|
||||
- name: Checkout LFS
|
||||
uses: ./
|
||||
with:
|
||||
repository: actions/checkout # hardcoded, otherwise doesn't work from a fork
|
||||
ref: test-data/v2/lfs
|
||||
path: lfs
|
||||
lfs: true
|
||||
- name: Verify LFS
|
||||
shell: bash
|
||||
run: __test__/verify-lfs.sh
|
||||
|
||||
# Submodules false
|
||||
- name: Checkout submodules false
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/submodule-ssh-url
|
||||
path: submodules-false
|
||||
- name: Verify submodules false
|
||||
run: __test__/verify-submodules-false.sh
|
||||
|
||||
# Submodules one level
|
||||
- name: Checkout submodules true
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/submodule-ssh-url
|
||||
path: submodules-true
|
||||
submodules: true
|
||||
- name: Verify submodules true
|
||||
run: __test__/verify-submodules-true.sh
|
||||
|
||||
# Submodules recursive
|
||||
- name: Checkout submodules recursive
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/submodule-ssh-url
|
||||
path: submodules-recursive
|
||||
submodules: recursive
|
||||
- name: Verify submodules recursive
|
||||
run: __test__/verify-submodules-recursive.sh
|
||||
|
||||
# Basic checkout using REST API
|
||||
- name: Remove basic
|
||||
if: runner.os != 'windows'
|
||||
run: rm -rf basic
|
||||
- name: Remove basic (Windows)
|
||||
if: runner.os == 'windows'
|
||||
shell: cmd
|
||||
run: rmdir /s /q basic
|
||||
- name: Override git version
|
||||
if: runner.os != 'windows'
|
||||
run: __test__/override-git-version.sh
|
||||
- name: Override git version (Windows)
|
||||
if: runner.os == 'windows'
|
||||
run: __test__\\override-git-version.cmd
|
||||
- name: Checkout basic using REST API
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
run: __test__/verify-basic.sh --archive
|
||||
|
||||
test-proxy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: alpine/git:latest
|
||||
options: --dns 127.0.0.1
|
||||
services:
|
||||
squid-proxy:
|
||||
image: datadog/squid:latest
|
||||
ports:
|
||||
- 3128:3128
|
||||
env:
|
||||
https_proxy: http://squid-proxy:3128
|
||||
steps:
|
||||
# Clone this repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Basic checkout using git
|
||||
- name: Checkout basic
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
run: __test__/verify-basic.sh
|
||||
|
||||
# Basic checkout using REST API
|
||||
- name: Remove basic
|
||||
run: rm -rf basic
|
||||
- name: Override git version
|
||||
run: __test__/override-git-version.sh
|
||||
- name: Basic checkout using REST API
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
run: __test__/verify-basic.sh --archive
|
||||
|
||||
test-bypass-proxy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
https_proxy: http://no-such-proxy:3128
|
||||
no_proxy: api.github.com,github.com
|
||||
steps:
|
||||
# Clone this repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Basic checkout using git
|
||||
- name: Checkout basic
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
run: __test__/verify-basic.sh
|
||||
- name: Remove basic
|
||||
run: rm -rf basic
|
||||
|
||||
# Basic checkout using REST API
|
||||
- name: Override git version
|
||||
run: __test__/override-git-version.sh
|
||||
- name: Checkout basic using REST API
|
||||
uses: ./
|
||||
with:
|
||||
ref: test-data/v2/basic
|
||||
path: basic
|
||||
- name: Verify basic
|
||||
run: __test__/verify-basic.sh --archive
|
|
@ -0,0 +1,3 @@
|
|||
__test__/_temp
|
||||
lib/
|
||||
node_modules/
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
lib/
|
||||
node_modules/
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": false,
|
||||
"arrowParens": "avoid",
|
||||
"parser": "typescript"
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
# Changelog
|
||||
|
||||
## v2.3.1
|
||||
|
||||
- [Fix default branch resolution for .wiki and when using SSH](https://github.com/actions/checkout/pull/284)
|
||||
|
||||
|
||||
## v2.3.0
|
||||
|
||||
- [Fallback to the default branch](https://github.com/actions/checkout/pull/278)
|
||||
|
||||
## v2.2.0
|
||||
|
||||
- [Fetch all history for all tags and branches when fetch-depth=0](https://github.com/actions/checkout/pull/258)
|
||||
|
||||
## v2.1.1
|
||||
|
||||
- Changes to support GHES ([here](https://github.com/actions/checkout/pull/236) and [here](https://github.com/actions/checkout/pull/248))
|
||||
|
||||
## v2.1.0
|
||||
|
||||
- [Group output](https://github.com/actions/checkout/pull/191)
|
||||
- [Changes to support GHES alpha release](https://github.com/actions/checkout/pull/199)
|
||||
- [Persist core.sshCommand for submodules](https://github.com/actions/checkout/pull/184)
|
||||
- [Add support ssh](https://github.com/actions/checkout/pull/163)
|
||||
- [Convert submodule SSH URL to HTTPS, when not using SSH](https://github.com/actions/checkout/pull/179)
|
||||
- [Add submodule support](https://github.com/actions/checkout/pull/157)
|
||||
- [Follow proxy settings](https://github.com/actions/checkout/pull/144)
|
||||
- [Fix ref for pr closed event when a pr is merged](https://github.com/actions/checkout/pull/141)
|
||||
- [Fix issue checking detached when git less than 2.22](https://github.com/actions/checkout/pull/128)
|
||||
|
||||
## v2.0.0
|
||||
|
||||
- [Do not pass cred on command line](https://github.com/actions/checkout/pull/108)
|
||||
- [Add input persist-credentials](https://github.com/actions/checkout/pull/107)
|
||||
- [Fallback to REST API to download repo](https://github.com/actions/checkout/pull/104)
|
||||
|
||||
## v2 (beta)
|
||||
|
||||
- Improved fetch performance
|
||||
- The default behavior now fetches only the SHA being checked-out
|
||||
- Script authenticated git commands
|
||||
- Persists `with.token` in the local git config
|
||||
- Enables your scripts to run authenticated git commands
|
||||
- Post-job cleanup removes the token
|
||||
- Coming soon: Opt out by setting `with.persist-credentials` to `false`
|
||||
- Creates a local branch
|
||||
- No longer detached HEAD when checking out a branch
|
||||
- A local branch is created with the corresponding upstream branch set
|
||||
- Improved layout
|
||||
- `with.path` is always relative to `github.workspace`
|
||||
- Aligns better with container actions, where `github.workspace` gets mapped in
|
||||
- Removed input `submodules`
|
||||
|
||||
|
||||
## v1
|
||||
|
||||
Refer [here](https://github.com/actions/checkout/blob/v1/CHANGELOG.md) for the V1 changelog
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 GitHub, Inc. and 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.
|
|
@ -0,0 +1,218 @@
|
|||
<p align="center">
|
||||
<a href="https://github.com/actions/checkout"><img alt="GitHub Actions status" src="https://github.com/actions/checkout/workflows/test-local/badge.svg"></a>
|
||||
</p>
|
||||
|
||||
# Checkout V2
|
||||
|
||||
This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it.
|
||||
|
||||
Only a single commit is fetched by default, for the ref/SHA that triggered the workflow. Set `fetch-depth: 0` to fetch all history for all branches and tags. Refer [here](https://help.github.com/en/articles/events-that-trigger-workflows) to learn which commit `$GITHUB_SHA` points to for different events.
|
||||
|
||||
The auth token is persisted in the local git config. This enables your scripts to run authenticated git commands. The token is removed during post-job cleanup. Set `persist-credentials: false` to opt-out.
|
||||
|
||||
When Git 2.18 or higher is not in your PATH, falls back to the REST API to download the files.
|
||||
|
||||
# What's new
|
||||
|
||||
- Improved performance
|
||||
- Fetches only a single commit by default
|
||||
- Script authenticated git commands
|
||||
- Auth token persisted in the local git config
|
||||
- Supports SSH
|
||||
- Creates a local branch
|
||||
- No longer detached HEAD when checking out a branch
|
||||
- Improved layout
|
||||
- The input `path` is always relative to $GITHUB_WORKSPACE
|
||||
- Aligns better with container actions, where $GITHUB_WORKSPACE gets mapped in
|
||||
- Fallback to REST API download
|
||||
- When Git 2.18 or higher is not in the PATH, the REST API will be used to download the files
|
||||
- When using a job container, the container's PATH is used
|
||||
|
||||
Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous versions.
|
||||
|
||||
# Usage
|
||||
|
||||
<!-- start usage -->
|
||||
```yaml
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
# Repository name with owner. For example, actions/checkout
|
||||
# Default: ${{ github.repository }}
|
||||
repository: ''
|
||||
|
||||
# The branch, tag or SHA to checkout. When checking out the repository that
|
||||
# triggered a workflow, this defaults to the reference or SHA for that event.
|
||||
# Otherwise, uses the default branch.
|
||||
ref: ''
|
||||
|
||||
# Personal access token (PAT) used to fetch the repository. The PAT is configured
|
||||
# with the local git config, which enables your scripts to run authenticated git
|
||||
# commands. The post-job step removes the PAT.
|
||||
#
|
||||
# We recommend using a service account with the least permissions necessary. Also
|
||||
# when generating a new PAT, select the least scopes necessary.
|
||||
#
|
||||
# [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
#
|
||||
# Default: ${{ github.token }}
|
||||
token: ''
|
||||
|
||||
# SSH key used to fetch the repository. The SSH key is configured with the local
|
||||
# git config, which enables your scripts to run authenticated git commands. The
|
||||
# post-job step removes the SSH key.
|
||||
#
|
||||
# We recommend using a service account with the least permissions necessary.
|
||||
#
|
||||
# [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
ssh-key: ''
|
||||
|
||||
# Known hosts in addition to the user and global host key database. The public SSH
|
||||
# keys for a host may be obtained using the utility `ssh-keyscan`. For example,
|
||||
# `ssh-keyscan github.com`. The public key for github.com is always implicitly
|
||||
# added.
|
||||
ssh-known-hosts: ''
|
||||
|
||||
# Whether to perform strict host key checking. When true, adds the options
|
||||
# `StrictHostKeyChecking=yes` and `CheckHostIP=no` to the SSH command line. Use
|
||||
# the input `ssh-known-hosts` to configure additional hosts.
|
||||
# Default: true
|
||||
ssh-strict: ''
|
||||
|
||||
# Whether to configure the token or SSH key with the local git config
|
||||
# Default: true
|
||||
persist-credentials: ''
|
||||
|
||||
# Relative path under $GITHUB_WORKSPACE to place the repository
|
||||
path: ''
|
||||
|
||||
# Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching
|
||||
# Default: true
|
||||
clean: ''
|
||||
|
||||
# Number of commits to fetch. 0 indicates all history for all branches and tags.
|
||||
# Default: 1
|
||||
fetch-depth: ''
|
||||
|
||||
# Whether to download Git-LFS files
|
||||
# Default: false
|
||||
lfs: ''
|
||||
|
||||
# Whether to checkout submodules: `true` to checkout submodules or `recursive` to
|
||||
# recursively checkout submodules.
|
||||
#
|
||||
# When the `ssh-key` input is not provided, SSH URLs beginning with
|
||||
# `git@github.com:` are converted to HTTPS.
|
||||
#
|
||||
# Default: false
|
||||
submodules: ''
|
||||
```
|
||||
<!-- end usage -->
|
||||
|
||||
# Scenarios
|
||||
|
||||
- [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches)
|
||||
- [Checkout a different branch](#Checkout-a-different-branch)
|
||||
- [Checkout HEAD^](#Checkout-HEAD)
|
||||
- [Checkout multiple repos (side by side)](#Checkout-multiple-repos-side-by-side)
|
||||
- [Checkout multiple repos (nested)](#Checkout-multiple-repos-nested)
|
||||
- [Checkout multiple repos (private)](#Checkout-multiple-repos-private)
|
||||
- [Checkout pull request HEAD commit instead of merge commit](#Checkout-pull-request-HEAD-commit-instead-of-merge-commit)
|
||||
- [Checkout pull request on closed event](#Checkout-pull-request-on-closed-event)
|
||||
|
||||
## Fetch all history for all tags and branches
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
```
|
||||
|
||||
## Checkout a different branch
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: my-branch
|
||||
```
|
||||
|
||||
## Checkout HEAD^
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- run: git checkout HEAD^
|
||||
```
|
||||
|
||||
## Checkout multiple repos (side by side)
|
||||
|
||||
```yaml
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: main
|
||||
|
||||
- name: Checkout tools repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: my-org/my-tools
|
||||
path: my-tools
|
||||
```
|
||||
|
||||
## Checkout multiple repos (nested)
|
||||
|
||||
```yaml
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Checkout tools repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: my-org/my-tools
|
||||
path: my-tools
|
||||
```
|
||||
|
||||
## Checkout multiple repos (private)
|
||||
|
||||
```yaml
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: main
|
||||
|
||||
- name: Checkout private tools
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: my-org/my-private-tools
|
||||
token: ${{ secrets.GitHub_PAT }} # `GitHub_PAT` is a secret that contains your PAT
|
||||
path: my-tools
|
||||
```
|
||||
|
||||
> - `${{ github.token }}` is scoped to the current repository, so if you want to checkout a different repository that is private you will need to provide your own [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line).
|
||||
|
||||
|
||||
## Checkout pull request HEAD commit instead of merge commit
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
```
|
||||
|
||||
## Checkout pull request on closed event
|
||||
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
types: [opened, synchronize, closed]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
```
|
||||
|
||||
# License
|
||||
|
||||
The scripts and documentation in this project are released under the [MIT License](LICENSE)
|
|
@ -0,0 +1,802 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as gitAuthHelper from '../lib/git-auth-helper'
|
||||
import * as io from '@actions/io'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as stateHelper from '../lib/state-helper'
|
||||
import {IGitCommandManager} from '../lib/git-command-manager'
|
||||
import {IGitSourceSettings} from '../lib/git-source-settings'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper')
|
||||
const originalRunnerTemp = process.env['RUNNER_TEMP']
|
||||
const originalHome = process.env['HOME']
|
||||
let workspace: string
|
||||
let localGitConfigPath: string
|
||||
let globalGitConfigPath: string
|
||||
let runnerTemp: string
|
||||
let tempHomedir: string
|
||||
let git: IGitCommandManager & {env: {[key: string]: string}}
|
||||
let settings: IGitSourceSettings
|
||||
let sshPath: string
|
||||
|
||||
describe('git-auth-helper tests', () => {
|
||||
beforeAll(async () => {
|
||||
// SSH
|
||||
sshPath = await io.which('ssh')
|
||||
|
||||
// Clear test workspace
|
||||
await io.rmRF(testWorkspace)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// 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(() => {
|
||||
// Unregister mocks
|
||||
jest.restoreAllMocks()
|
||||
|
||||
// Restore HOME
|
||||
if (originalHome) {
|
||||
process.env['HOME'] = originalHome
|
||||
} else {
|
||||
delete process.env['HOME']
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Restore RUNNER_TEMP
|
||||
delete process.env['RUNNER_TEMP']
|
||||
if (originalRunnerTemp) {
|
||||
process.env['RUNNER_TEMP'] = originalRunnerTemp
|
||||
}
|
||||
})
|
||||
|
||||
const configureAuth_configuresAuthHeader =
|
||||
'configureAuth configures auth header'
|
||||
it(configureAuth_configuresAuthHeader, async () => {
|
||||
// Arrange
|
||||
await setup(configureAuth_configuresAuthHeader)
|
||||
expect(settings.authToken).toBeTruthy() // sanity check
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert config
|
||||
const configContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
const basicCredential = Buffer.from(
|
||||
`x-access-token:${settings.authToken}`,
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
expect(
|
||||
configContent.indexOf(
|
||||
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||
)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
const configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse =
|
||||
'configureAuth configures auth header even when persist credentials false'
|
||||
it(
|
||||
configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse,
|
||||
async () => {
|
||||
// Arrange
|
||||
await setup(
|
||||
configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse
|
||||
)
|
||||
expect(settings.authToken).toBeTruthy() // sanity check
|
||||
settings.persistCredentials = false
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert config
|
||||
const configContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
expect(
|
||||
configContent.indexOf(
|
||||
`http.https://github.com/.extraheader AUTHORIZATION`
|
||||
)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
)
|
||||
|
||||
const configureAuth_copiesUserKnownHosts =
|
||||
'configureAuth copies user known hosts'
|
||||
it(configureAuth_copiesUserKnownHosts, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureAuth_copiesUserKnownHosts}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arange
|
||||
await setup(configureAuth_copiesUserKnownHosts)
|
||||
expect(settings.sshKey).toBeTruthy() // sanity check
|
||||
|
||||
// Mock fs.promises.readFile
|
||||
const realReadFile = fs.promises.readFile
|
||||
jest.spyOn(fs.promises, 'readFile').mockImplementation(
|
||||
async (file: any, options: any): Promise<Buffer> => {
|
||||
const userKnownHostsPath = path.join(
|
||||
os.homedir(),
|
||||
'.ssh',
|
||||
'known_hosts'
|
||||
)
|
||||
if (file === userKnownHostsPath) {
|
||||
return Buffer.from('some-domain.com ssh-rsa ABCDEF')
|
||||
}
|
||||
|
||||
return await realReadFile(file, options)
|
||||
}
|
||||
)
|
||||
|
||||
// Act
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert known hosts
|
||||
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
const actualSshKnownHostsContent = (
|
||||
await fs.promises.readFile(actualSshKnownHostsPath)
|
||||
).toString()
|
||||
expect(actualSshKnownHostsContent).toMatch(
|
||||
/some-domain\.com ssh-rsa ABCDEF/
|
||||
)
|
||||
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
|
||||
})
|
||||
|
||||
const configureAuth_registersBasicCredentialAsSecret =
|
||||
'configureAuth registers basic credential as secret'
|
||||
it(configureAuth_registersBasicCredentialAsSecret, async () => {
|
||||
// Arrange
|
||||
await setup(configureAuth_registersBasicCredentialAsSecret)
|
||||
expect(settings.authToken).toBeTruthy() // sanity check
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert secret
|
||||
const setSecretSpy = core.setSecret as jest.Mock<any, any>
|
||||
expect(setSecretSpy).toHaveBeenCalledTimes(1)
|
||||
const expectedSecret = Buffer.from(
|
||||
`x-access-token:${settings.authToken}`,
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret)
|
||||
})
|
||||
|
||||
const setsSshCommandEnvVarWhenPersistCredentialsFalse =
|
||||
'sets SSH command env var when persist-credentials false'
|
||||
it(setsSshCommandEnvVarWhenPersistCredentialsFalse, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${setsSshCommandEnvVarWhenPersistCredentialsFalse}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(setsSshCommandEnvVarWhenPersistCredentialsFalse)
|
||||
settings.persistCredentials = false
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert git env var
|
||||
const actualKeyPath = await getActualSshKeyPath()
|
||||
const actualKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
|
||||
actualKeyPath
|
||||
)}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
|
||||
actualKnownHostsPath
|
||||
)}"`
|
||||
expect(git.setEnvironmentVariable).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expectedSshCommand
|
||||
)
|
||||
|
||||
// Asserty git config
|
||||
const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(x => x)
|
||||
expect(gitConfigLines).toHaveLength(1)
|
||||
expect(gitConfigLines[0]).toMatch(/^http\./)
|
||||
})
|
||||
|
||||
const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
|
||||
'sets SSH command when persist-credentials true'
|
||||
it(configureAuth_setsSshCommandWhenPersistCredentialsTrue, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureAuth_setsSshCommandWhenPersistCredentialsTrue}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(configureAuth_setsSshCommandWhenPersistCredentialsTrue)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert git env var
|
||||
const actualKeyPath = await getActualSshKeyPath()
|
||||
const actualKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
|
||||
actualKeyPath
|
||||
)}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
|
||||
actualKnownHostsPath
|
||||
)}"`
|
||||
expect(git.setEnvironmentVariable).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expectedSshCommand
|
||||
)
|
||||
|
||||
// Asserty git config
|
||||
expect(git.config).toHaveBeenCalledWith(
|
||||
'core.sshCommand',
|
||||
expectedSshCommand
|
||||
)
|
||||
})
|
||||
|
||||
const configureAuth_writesExplicitKnownHosts = 'writes explicit known hosts'
|
||||
it(configureAuth_writesExplicitKnownHosts, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureAuth_writesExplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(configureAuth_writesExplicitKnownHosts)
|
||||
expect(settings.sshKey).toBeTruthy() // sanity check
|
||||
settings.sshKnownHosts = 'my-custom-host.com ssh-rsa ABC123'
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert known hosts
|
||||
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
const actualSshKnownHostsContent = (
|
||||
await fs.promises.readFile(actualSshKnownHostsPath)
|
||||
).toString()
|
||||
expect(actualSshKnownHostsContent).toMatch(
|
||||
/my-custom-host\.com ssh-rsa ABC123/
|
||||
)
|
||||
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
|
||||
})
|
||||
|
||||
const configureAuth_writesSshKeyAndImplicitKnownHosts =
|
||||
'writes SSH key and implicit known hosts'
|
||||
it(configureAuth_writesSshKeyAndImplicitKnownHosts, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureAuth_writesSshKeyAndImplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(configureAuth_writesSshKeyAndImplicitKnownHosts)
|
||||
expect(settings.sshKey).toBeTruthy() // sanity check
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
|
||||
// Assert SSH key
|
||||
const actualSshKeyPath = await getActualSshKeyPath()
|
||||
expect(actualSshKeyPath).toBeTruthy()
|
||||
const actualSshKeyContent = (
|
||||
await fs.promises.readFile(actualSshKeyPath)
|
||||
).toString()
|
||||
expect(actualSshKeyContent).toBe(settings.sshKey + '\n')
|
||||
if (!isWindows) {
|
||||
// Assert read/write for user, not group or others.
|
||||
// Otherwise SSH client will error.
|
||||
expect((await fs.promises.stat(actualSshKeyPath)).mode & 0o777).toBe(
|
||||
0o600
|
||||
)
|
||||
}
|
||||
|
||||
// Assert known hosts
|
||||
const actualSshKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
const actualSshKnownHostsContent = (
|
||||
await fs.promises.readFile(actualSshKnownHostsPath)
|
||||
).toString()
|
||||
expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/)
|
||||
})
|
||||
|
||||
const configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet =
|
||||
'configureGlobalAuth configures URL insteadOf when SSH key not set'
|
||||
it(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet, async () => {
|
||||
// Arrange
|
||||
await setup(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet)
|
||||
settings.sshKey = ''
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
await authHelper.configureGlobalAuth()
|
||||
|
||||
// Assert temporary global config
|
||||
expect(git.env['HOME']).toBeTruthy()
|
||||
const configContent = (
|
||||
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
|
||||
).toString()
|
||||
expect(
|
||||
configContent.indexOf(`url.https://github.com/.insteadOf git@github.com`)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
const configureGlobalAuth_copiesGlobalGitConfig =
|
||||
'configureGlobalAuth copies global git config'
|
||||
it(configureGlobalAuth_copiesGlobalGitConfig, async () => {
|
||||
// Arrange
|
||||
await setup(configureGlobalAuth_copiesGlobalGitConfig)
|
||||
await fs.promises.writeFile(globalGitConfigPath, 'value-from-global-config')
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
await authHelper.configureGlobalAuth()
|
||||
|
||||
// Assert original global config not altered
|
||||
let configContent = (
|
||||
await fs.promises.readFile(globalGitConfigPath)
|
||||
).toString()
|
||||
expect(configContent).toBe('value-from-global-config')
|
||||
|
||||
// Assert temporary global config
|
||||
expect(git.env['HOME']).toBeTruthy()
|
||||
const basicCredential = Buffer.from(
|
||||
`x-access-token:${settings.authToken}`,
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
configContent = (
|
||||
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
|
||||
).toString()
|
||||
expect(
|
||||
configContent.indexOf('value-from-global-config')
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
configContent.indexOf(
|
||||
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||
)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
const configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist =
|
||||
'configureGlobalAuth creates new git config when global does not exist'
|
||||
it(
|
||||
configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist,
|
||||
async () => {
|
||||
// Arrange
|
||||
await setup(
|
||||
configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist
|
||||
)
|
||||
await io.rmRF(globalGitConfigPath)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
|
||||
// Act
|
||||
await authHelper.configureAuth()
|
||||
await authHelper.configureGlobalAuth()
|
||||
|
||||
// Assert original global config not recreated
|
||||
try {
|
||||
await fs.promises.stat(globalGitConfigPath)
|
||||
throw new Error(
|
||||
`Did not expect file to exist: '${globalGitConfigPath}'`
|
||||
)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Assert temporary global config
|
||||
expect(git.env['HOME']).toBeTruthy()
|
||||
const basicCredential = Buffer.from(
|
||||
`x-access-token:${settings.authToken}`,
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
const configContent = (
|
||||
await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
|
||||
).toString()
|
||||
expect(
|
||||
configContent.indexOf(
|
||||
`http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
|
||||
)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
)
|
||||
|
||||
const configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeyNotSet =
|
||||
'configureSubmoduleAuth configures submodules when persist credentials false and SSH key not set'
|
||||
it(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeyNotSet,
|
||||
async () => {
|
||||
// Arrange
|
||||
await setup(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeyNotSet
|
||||
)
|
||||
settings.persistCredentials = false
|
||||
settings.sshKey = ''
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
|
||||
mockSubmoduleForeach.mockClear() // reset calls
|
||||
|
||||
// Act
|
||||
await authHelper.configureSubmoduleAuth()
|
||||
|
||||
// Assert
|
||||
expect(mockSubmoduleForeach).toBeCalledTimes(1)
|
||||
expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch(
|
||||
/unset-all.*insteadOf/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeySet =
|
||||
'configureSubmoduleAuth configures submodules when persist credentials false and SSH key set'
|
||||
it(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeySet,
|
||||
async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeySet}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsFalseAndSshKeySet
|
||||
)
|
||||
settings.persistCredentials = false
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
|
||||
mockSubmoduleForeach.mockClear() // reset calls
|
||||
|
||||
// Act
|
||||
await authHelper.configureSubmoduleAuth()
|
||||
|
||||
// Assert
|
||||
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(1)
|
||||
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
||||
/unset-all.*insteadOf/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeyNotSet =
|
||||
'configureSubmoduleAuth configures submodules when persist credentials true and SSH key not set'
|
||||
it(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeyNotSet,
|
||||
async () => {
|
||||
// Arrange
|
||||
await setup(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeyNotSet
|
||||
)
|
||||
settings.sshKey = ''
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
|
||||
mockSubmoduleForeach.mockClear() // reset calls
|
||||
|
||||
// Act
|
||||
await authHelper.configureSubmoduleAuth()
|
||||
|
||||
// Assert
|
||||
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
|
||||
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
||||
/unset-all.*insteadOf/
|
||||
)
|
||||
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
|
||||
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/url.*insteadOf/)
|
||||
}
|
||||
)
|
||||
|
||||
const configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeySet =
|
||||
'configureSubmoduleAuth configures submodules when persist credentials true and SSH key set'
|
||||
it(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeySet,
|
||||
async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(
|
||||
configureSubmoduleAuth_configuresSubmodulesWhenPersistCredentialsTrueAndSshKeySet
|
||||
)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any>
|
||||
mockSubmoduleForeach.mockClear() // reset calls
|
||||
|
||||
// Act
|
||||
await authHelper.configureSubmoduleAuth()
|
||||
|
||||
// Assert
|
||||
expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3)
|
||||
expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch(
|
||||
/unset-all.*insteadOf/
|
||||
)
|
||||
expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/)
|
||||
expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/)
|
||||
}
|
||||
)
|
||||
|
||||
const removeAuth_removesSshCommand = 'removeAuth removes SSH command'
|
||||
it(removeAuth_removesSshCommand, async () => {
|
||||
if (!sshPath) {
|
||||
process.stdout.write(
|
||||
`Skipped test "${removeAuth_removesSshCommand}". Executable 'ssh' not found in the PATH.\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrange
|
||||
await setup(removeAuth_removesSshCommand)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
let gitConfigContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
expect(gitConfigContent.indexOf('core.sshCommand')).toBeGreaterThanOrEqual(
|
||||
0
|
||||
) // sanity check
|
||||
const actualKeyPath = await getActualSshKeyPath()
|
||||
expect(actualKeyPath).toBeTruthy()
|
||||
await fs.promises.stat(actualKeyPath)
|
||||
const actualKnownHostsPath = await getActualSshKnownHostsPath()
|
||||
expect(actualKnownHostsPath).toBeTruthy()
|
||||
await fs.promises.stat(actualKnownHostsPath)
|
||||
|
||||
// Act
|
||||
await authHelper.removeAuth()
|
||||
|
||||
// Assert git config
|
||||
gitConfigContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
expect(gitConfigContent.indexOf('core.sshCommand')).toBeLessThan(0)
|
||||
|
||||
// Assert SSH key file
|
||||
try {
|
||||
await fs.promises.stat(actualKeyPath)
|
||||
throw new Error('SSH key should have been deleted')
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Assert known hosts file
|
||||
try {
|
||||
await fs.promises.stat(actualKnownHostsPath)
|
||||
throw new Error('SSH known hosts should have been deleted')
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const removeAuth_removesToken = 'removeAuth removes token'
|
||||
it(removeAuth_removesToken, async () => {
|
||||
// Arrange
|
||||
await setup(removeAuth_removesToken)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
let gitConfigContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check
|
||||
|
||||
// Act
|
||||
await authHelper.removeAuth()
|
||||
|
||||
// Assert git config
|
||||
gitConfigContent = (
|
||||
await fs.promises.readFile(localGitConfigPath)
|
||||
).toString()
|
||||
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
const removeGlobalAuth_removesOverride = 'removeGlobalAuth removes override'
|
||||
it(removeGlobalAuth_removesOverride, async () => {
|
||||
// Arrange
|
||||
await setup(removeGlobalAuth_removesOverride)
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
await authHelper.configureAuth()
|
||||
await authHelper.configureGlobalAuth()
|
||||
const homeOverride = git.env['HOME'] // Sanity check
|
||||
expect(homeOverride).toBeTruthy()
|
||||
await fs.promises.stat(path.join(git.env['HOME'], '.gitconfig'))
|
||||
|
||||
// Act
|
||||
await authHelper.removeGlobalAuth()
|
||||
|
||||
// Assert
|
||||
expect(git.env['HOME']).toBeUndefined()
|
||||
try {
|
||||
await fs.promises.stat(homeOverride)
|
||||
throw new Error(`Should have been deleted '${homeOverride}'`)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(testName: string): Promise<void> {
|
||||
testName = testName.replace(/[^a-zA-Z0-9_]+/g, '-')
|
||||
|
||||
// Directories
|
||||
workspace = path.join(testWorkspace, testName, 'workspace')
|
||||
runnerTemp = path.join(testWorkspace, testName, 'runner-temp')
|
||||
tempHomedir = path.join(testWorkspace, testName, 'home-dir')
|
||||
await fs.promises.mkdir(workspace, {recursive: true})
|
||||
await fs.promises.mkdir(runnerTemp, {recursive: true})
|
||||
await fs.promises.mkdir(tempHomedir, {recursive: true})
|
||||
process.env['RUNNER_TEMP'] = runnerTemp
|
||||
process.env['HOME'] = tempHomedir
|
||||
|
||||
// Create git config
|
||||
globalGitConfigPath = path.join(tempHomedir, '.gitconfig')
|
||||
await fs.promises.writeFile(globalGitConfigPath, '')
|
||||
localGitConfigPath = path.join(workspace, '.git', 'config')
|
||||
await fs.promises.mkdir(path.dirname(localGitConfigPath), {recursive: true})
|
||||
await fs.promises.writeFile(localGitConfigPath, '')
|
||||
|
||||
git = {
|
||||
branchDelete: jest.fn(),
|
||||
branchExists: jest.fn(),
|
||||
branchList: jest.fn(),
|
||||
checkout: jest.fn(),
|
||||
checkoutDetach: jest.fn(),
|
||||
config: jest.fn(
|
||||
async (key: string, value: string, globalConfig?: boolean) => {
|
||||
const configPath = globalConfig
|
||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
||||
: localGitConfigPath
|
||||
await fs.promises.appendFile(configPath, `\n${key} ${value}`)
|
||||
}
|
||||
),
|
||||
configExists: jest.fn(
|
||||
async (key: string, globalConfig?: boolean): Promise<boolean> => {
|
||||
const configPath = globalConfig
|
||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
||||
: localGitConfigPath
|
||||
const content = await fs.promises.readFile(configPath)
|
||||
const lines = content
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(x => x)
|
||||
return lines.some(x => x.startsWith(key))
|
||||
}
|
||||
),
|
||||
env: {},
|
||||
fetch: jest.fn(),
|
||||
getDefaultBranch: jest.fn(),
|
||||
getWorkingDirectory: jest.fn(() => workspace),
|
||||
init: jest.fn(),
|
||||
isDetached: jest.fn(),
|
||||
lfsFetch: jest.fn(),
|
||||
lfsInstall: jest.fn(),
|
||||
log1: jest.fn(),
|
||||
remoteAdd: jest.fn(),
|
||||
removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]),
|
||||
revParse: jest.fn(),
|
||||
setEnvironmentVariable: jest.fn((name: string, value: string) => {
|
||||
git.env[name] = value
|
||||
}),
|
||||
shaExists: jest.fn(),
|
||||
submoduleForeach: jest.fn(async () => {
|
||||
return ''
|
||||
}),
|
||||
submoduleSync: jest.fn(),
|
||||
submoduleUpdate: jest.fn(),
|
||||
tagExists: jest.fn(),
|
||||
tryClean: jest.fn(),
|
||||
tryConfigUnset: jest.fn(
|
||||
async (key: string, globalConfig?: boolean): Promise<boolean> => {
|
||||
const configPath = globalConfig
|
||||
? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
|
||||
: localGitConfigPath
|
||||
let content = await fs.promises.readFile(configPath)
|
||||
let lines = content
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(x => x)
|
||||
.filter(x => !x.startsWith(key))
|
||||
await fs.promises.writeFile(configPath, lines.join('\n'))
|
||||
return true
|
||||
}
|
||||
),
|
||||
tryDisableAutomaticGarbageCollection: jest.fn(),
|
||||
tryGetFetchUrl: jest.fn(),
|
||||
tryReset: jest.fn()
|
||||
}
|
||||
|
||||
settings = {
|
||||
authToken: 'some auth token',
|
||||
clean: true,
|
||||
commit: '',
|
||||
fetchDepth: 1,
|
||||
lfs: false,
|
||||
submodules: false,
|
||||
nestedSubmodules: false,
|
||||
persistCredentials: true,
|
||||
ref: 'refs/heads/master',
|
||||
repositoryName: 'my-repo',
|
||||
repositoryOwner: 'my-org',
|
||||
repositoryPath: '',
|
||||
sshKey: sshPath ? 'some ssh private key' : '',
|
||||
sshKnownHosts: '',
|
||||
sshStrict: true
|
||||
}
|
||||
}
|
||||
|
||||
async function getActualSshKeyPath(): Promise<string> {
|
||||
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
||||
.sort()
|
||||
.map(x => path.join(runnerTemp, x))
|
||||
if (actualTempFiles.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
expect(actualTempFiles).toHaveLength(2)
|
||||
expect(actualTempFiles[0].endsWith('_known_hosts')).toBeFalsy()
|
||||
return actualTempFiles[0]
|
||||
}
|
||||
|
||||
async function getActualSshKnownHostsPath(): Promise<string> {
|
||||
let actualTempFiles = (await fs.promises.readdir(runnerTemp))
|
||||
.sort()
|
||||
.map(x => path.join(runnerTemp, x))
|
||||
if (actualTempFiles.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
expect(actualTempFiles).toHaveLength(2)
|
||||
expect(actualTempFiles[1].endsWith('_known_hosts')).toBeTruthy()
|
||||
expect(actualTempFiles[1].startsWith(actualTempFiles[0])).toBeTruthy()
|
||||
return actualTempFiles[1]
|
||||
}
|
|
@ -0,0 +1,441 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as gitDirectoryHelper from '../lib/git-directory-helper'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import {IGitCommandManager} from '../lib/git-command-manager'
|
||||
|
||||
const testWorkspace = path.join(__dirname, '_temp', 'git-directory-helper')
|
||||
let repositoryPath: string
|
||||
let repositoryUrl: string
|
||||
let clean: boolean
|
||||
let ref: string
|
||||
let git: IGitCommandManager
|
||||
|
||||
describe('git-directory-helper tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Clear test workspace
|
||||
await io.rmRF(testWorkspace)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// 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(() => {
|
||||
// Unregister mocks
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
const cleansWhenCleanTrue = 'cleans when clean true'
|
||||
it(cleansWhenCleanTrue, async () => {
|
||||
// Arrange
|
||||
await setup(cleansWhenCleanTrue)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.tryClean).toHaveBeenCalled()
|
||||
expect(git.tryReset).toHaveBeenCalled()
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const checkoutDetachWhenNotDetached = 'checkout detach when not detached'
|
||||
it(checkoutDetachWhenNotDetached, async () => {
|
||||
// Arrange
|
||||
await setup(checkoutDetachWhenNotDetached)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.checkoutDetach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const doesNotCheckoutDetachWhenNotAlreadyDetached =
|
||||
'does not checkout detach when already detached'
|
||||
it(doesNotCheckoutDetachWhenNotAlreadyDetached, async () => {
|
||||
// Arrange
|
||||
await setup(doesNotCheckoutDetachWhenNotAlreadyDetached)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
const mockIsDetached = git.isDetached as jest.Mock<any, any>
|
||||
mockIsDetached.mockImplementation(async () => {
|
||||
return true
|
||||
})
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.checkoutDetach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const doesNotCleanWhenCleanFalse = 'does not clean when clean false'
|
||||
it(doesNotCleanWhenCleanFalse, async () => {
|
||||
// Arrange
|
||||
await setup(doesNotCleanWhenCleanFalse)
|
||||
clean = false
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.isDetached).toHaveBeenCalled()
|
||||
expect(git.branchList).toHaveBeenCalled()
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(git.tryClean).not.toHaveBeenCalled()
|
||||
expect(git.tryReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesContentsWhenCleanFails = 'removes contents when clean fails'
|
||||
it(removesContentsWhenCleanFails, async () => {
|
||||
// Arrange
|
||||
await setup(removesContentsWhenCleanFails)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
let mockTryClean = git.tryClean as jest.Mock<any, any>
|
||||
mockTryClean.mockImplementation(async () => {
|
||||
return false
|
||||
})
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files).toHaveLength(0)
|
||||
expect(git.tryClean).toHaveBeenCalled()
|
||||
expect(core.warning).toHaveBeenCalled()
|
||||
expect(git.tryReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesContentsWhenDifferentRepositoryUrl =
|
||||
'removes contents when different repository url'
|
||||
it(removesContentsWhenDifferentRepositoryUrl, async () => {
|
||||
// Arrange
|
||||
await setup(removesContentsWhenDifferentRepositoryUrl)
|
||||
clean = false
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
const differentRepositoryUrl =
|
||||
'https://github.com/my-different-org/my-different-repo'
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
differentRepositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files).toHaveLength(0)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(git.isDetached).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesContentsWhenNoGitDirectory =
|
||||
'removes contents when no git directory'
|
||||
it(removesContentsWhenNoGitDirectory, async () => {
|
||||
// Arrange
|
||||
await setup(removesContentsWhenNoGitDirectory)
|
||||
clean = false
|
||||
await io.rmRF(path.join(repositoryPath, '.git'))
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files).toHaveLength(0)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(git.isDetached).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesContentsWhenResetFails = 'removes contents when reset fails'
|
||||
it(removesContentsWhenResetFails, async () => {
|
||||
// Arrange
|
||||
await setup(removesContentsWhenResetFails)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
let mockTryReset = git.tryReset as jest.Mock<any, any>
|
||||
mockTryReset.mockImplementation(async () => {
|
||||
return false
|
||||
})
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files).toHaveLength(0)
|
||||
expect(git.tryClean).toHaveBeenCalled()
|
||||
expect(git.tryReset).toHaveBeenCalled()
|
||||
expect(core.warning).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesContentsWhenUndefinedGitCommandManager =
|
||||
'removes contents when undefined git command manager'
|
||||
it(removesContentsWhenUndefinedGitCommandManager, async () => {
|
||||
// Arrange
|
||||
await setup(removesContentsWhenUndefinedGitCommandManager)
|
||||
clean = false
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
undefined,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files).toHaveLength(0)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesLocalBranches = 'removes local branches'
|
||||
it(removesLocalBranches, async () => {
|
||||
// Arrange
|
||||
await setup(removesLocalBranches)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
const mockBranchList = git.branchList as jest.Mock<any, any>
|
||||
mockBranchList.mockImplementation(async (remote: boolean) => {
|
||||
return remote ? [] : ['local-branch-1', 'local-branch-2']
|
||||
})
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.branchDelete).toHaveBeenCalledWith(false, 'local-branch-1')
|
||||
expect(git.branchDelete).toHaveBeenCalledWith(false, 'local-branch-2')
|
||||
})
|
||||
|
||||
const removesLockFiles = 'removes lock files'
|
||||
it(removesLockFiles, async () => {
|
||||
// Arrange
|
||||
await setup(removesLockFiles)
|
||||
clean = false
|
||||
await fs.promises.writeFile(
|
||||
path.join(repositoryPath, '.git', 'index.lock'),
|
||||
''
|
||||
)
|
||||
await fs.promises.writeFile(
|
||||
path.join(repositoryPath, '.git', 'shallow.lock'),
|
||||
''
|
||||
)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
let files = await fs.promises.readdir(path.join(repositoryPath, '.git'))
|
||||
expect(files).toHaveLength(0)
|
||||
files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.isDetached).toHaveBeenCalled()
|
||||
expect(git.branchList).toHaveBeenCalled()
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(git.tryClean).not.toHaveBeenCalled()
|
||||
expect(git.tryReset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const removesAncestorRemoteBranch = 'removes ancestor remote branch'
|
||||
it(removesAncestorRemoteBranch, async () => {
|
||||
// Arrange
|
||||
await setup(removesAncestorRemoteBranch)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
const mockBranchList = git.branchList as jest.Mock<any, any>
|
||||
mockBranchList.mockImplementation(async (remote: boolean) => {
|
||||
return remote ? ['origin/remote-branch-1', 'origin/remote-branch-2'] : []
|
||||
})
|
||||
ref = 'remote-branch-1/conflict'
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.branchDelete).toHaveBeenCalledTimes(1)
|
||||
expect(git.branchDelete).toHaveBeenCalledWith(
|
||||
true,
|
||||
'origin/remote-branch-1'
|
||||
)
|
||||
})
|
||||
|
||||
const removesDescendantRemoteBranches = 'removes descendant remote branch'
|
||||
it(removesDescendantRemoteBranches, async () => {
|
||||
// Arrange
|
||||
await setup(removesDescendantRemoteBranches)
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
const mockBranchList = git.branchList as jest.Mock<any, any>
|
||||
mockBranchList.mockImplementation(async (remote: boolean) => {
|
||||
return remote
|
||||
? ['origin/remote-branch-1/conflict', 'origin/remote-branch-2']
|
||||
: []
|
||||
})
|
||||
ref = 'remote-branch-1'
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'])
|
||||
expect(git.branchDelete).toHaveBeenCalledTimes(1)
|
||||
expect(git.branchDelete).toHaveBeenCalledWith(
|
||||
true,
|
||||
'origin/remote-branch-1/conflict'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(testName: string): Promise<void> {
|
||||
testName = testName.replace(/[^a-zA-Z0-9_]+/g, '-')
|
||||
|
||||
// Repository directory
|
||||
repositoryPath = path.join(testWorkspace, testName)
|
||||
await fs.promises.mkdir(path.join(repositoryPath, '.git'), {recursive: true})
|
||||
|
||||
// Repository URL
|
||||
repositoryUrl = 'https://github.com/my-org/my-repo'
|
||||
|
||||
// Clean
|
||||
clean = true
|
||||
|
||||
// Ref
|
||||
ref = ''
|
||||
|
||||
// Git command manager
|
||||
git = {
|
||||
branchDelete: jest.fn(),
|
||||
branchExists: jest.fn(),
|
||||
branchList: jest.fn(async () => {
|
||||
return []
|
||||
}),
|
||||
checkout: jest.fn(),
|
||||
checkoutDetach: jest.fn(),
|
||||
config: jest.fn(),
|
||||
configExists: jest.fn(),
|
||||
fetch: jest.fn(),
|
||||
getDefaultBranch: jest.fn(),
|
||||
getWorkingDirectory: jest.fn(() => repositoryPath),
|
||||
init: jest.fn(),
|
||||
isDetached: jest.fn(),
|
||||
lfsFetch: jest.fn(),
|
||||
lfsInstall: jest.fn(),
|
||||
log1: jest.fn(),
|
||||
remoteAdd: jest.fn(),
|
||||
removeEnvironmentVariable: jest.fn(),
|
||||
revParse: jest.fn(),
|
||||
setEnvironmentVariable: jest.fn(),
|
||||
shaExists: jest.fn(),
|
||||
submoduleForeach: jest.fn(),
|
||||
submoduleSync: jest.fn(),
|
||||
submoduleUpdate: jest.fn(),
|
||||
tagExists: jest.fn(),
|
||||
tryClean: jest.fn(async () => {
|
||||
return true
|
||||
}),
|
||||
tryConfigUnset: jest.fn(),
|
||||
tryDisableAutomaticGarbageCollection: jest.fn(),
|
||||
tryGetFetchUrl: jest.fn(async () => {
|
||||
// Sanity check - this function shouldn't be called when the .git directory doesn't exist
|
||||
await fs.promises.stat(path.join(repositoryPath, '.git'))
|
||||
return repositoryUrl
|
||||
}),
|
||||
tryReset: jest.fn(async () => {
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import {GitVersion} from '../lib/git-version'
|
||||
|
||||
describe('git-version tests', () => {
|
||||
it('basics', async () => {
|
||||
let version = new GitVersion('')
|
||||
expect(version.isValid()).toBeFalsy()
|
||||
|
||||
version = new GitVersion('asdf')
|
||||
expect(version.isValid()).toBeFalsy()
|
||||
|
||||
version = new GitVersion('1.2')
|
||||
expect(version.isValid()).toBeTruthy()
|
||||
expect(version.toString()).toBe('1.2')
|
||||
|
||||
version = new GitVersion('1.2.3')
|
||||
expect(version.isValid()).toBeTruthy()
|
||||
expect(version.toString()).toBe('1.2.3')
|
||||
})
|
||||
|
||||
it('check minimum', async () => {
|
||||
let version = new GitVersion('4.5')
|
||||
expect(version.checkMinimum(new GitVersion('3.6'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('3.6.7'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.4'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5.0'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.6'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('4.6.0'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('5.1'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('5.1.2'))).toBeFalsy()
|
||||
|
||||
version = new GitVersion('4.5.6')
|
||||
expect(version.checkMinimum(new GitVersion('3.6'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('3.6.7'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.4'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5.5'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5.6'))).toBeTruthy()
|
||||
expect(version.checkMinimum(new GitVersion('4.5.7'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('4.6'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('4.6.0'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('5.1'))).toBeFalsy()
|
||||
expect(version.checkMinimum(new GitVersion('5.1.2'))).toBeFalsy()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,126 @@
|
|||
import * as assert from 'assert'
|
||||
import * as core from '@actions/core'
|
||||
import * as fsHelper from '../lib/fs-helper'
|
||||
import * as github from '@actions/github'
|
||||
import * as inputHelper from '../lib/input-helper'
|
||||
import * as path from 'path'
|
||||
import {IGitSourceSettings} from '../lib/git-source-settings'
|
||||
|
||||
const originalGitHubWorkspace = process.env['GITHUB_WORKSPACE']
|
||||
const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
|
||||
|
||||
// Inputs for mock @actions/core
|
||||
let inputs = {} as any
|
||||
|
||||
// Shallow clone original @actions/github context
|
||||
let originalContext = {...github.context}
|
||||
|
||||
describe('input-helper tests', () => {
|
||||
beforeAll(() => {
|
||||
// Mock getInput
|
||||
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
||||
return inputs[name]
|
||||
})
|
||||
|
||||
// Mock error/warning/info/debug
|
||||
jest.spyOn(core, 'error').mockImplementation(jest.fn())
|
||||
jest.spyOn(core, 'warning').mockImplementation(jest.fn())
|
||||
jest.spyOn(core, 'info').mockImplementation(jest.fn())
|
||||
jest.spyOn(core, 'debug').mockImplementation(jest.fn())
|
||||
|
||||
// Mock github context
|
||||
jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => {
|
||||
return {
|
||||
owner: 'some-owner',
|
||||
repo: 'some-repo'
|
||||
}
|
||||
})
|
||||
github.context.ref = 'refs/heads/some-ref'
|
||||
github.context.sha = '1234567890123456789012345678901234567890'
|
||||
|
||||
// Mock ./fs-helper directoryExistsSync()
|
||||
jest
|
||||
.spyOn(fsHelper, 'directoryExistsSync')
|
||||
.mockImplementation((path: string) => path == gitHubWorkspace)
|
||||
|
||||
// GitHub workspace
|
||||
process.env['GITHUB_WORKSPACE'] = gitHubWorkspace
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset inputs
|
||||
inputs = {}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Restore GitHub workspace
|
||||
delete process.env['GITHUB_WORKSPACE']
|
||||
if (originalGitHubWorkspace) {
|
||||
process.env['GITHUB_WORKSPACE'] = originalGitHubWorkspace
|
||||
}
|
||||
|
||||
// Restore @actions/github context
|
||||
github.context.ref = originalContext.ref
|
||||
github.context.sha = originalContext.sha
|
||||
|
||||
// Restore
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sets defaults', () => {
|
||||
const settings: IGitSourceSettings = inputHelper.getInputs()
|
||||
expect(settings).toBeTruthy()
|
||||
expect(settings.authToken).toBeFalsy()
|
||||
expect(settings.clean).toBe(true)
|
||||
expect(settings.commit).toBeTruthy()
|
||||
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
|
||||
expect(settings.fetchDepth).toBe(1)
|
||||
expect(settings.lfs).toBe(false)
|
||||
expect(settings.ref).toBe('refs/heads/some-ref')
|
||||
expect(settings.repositoryName).toBe('some-repo')
|
||||
expect(settings.repositoryOwner).toBe('some-owner')
|
||||
expect(settings.repositoryPath).toBe(gitHubWorkspace)
|
||||
})
|
||||
|
||||
it('qualifies ref', () => {
|
||||
let originalRef = github.context.ref
|
||||
try {
|
||||
github.context.ref = 'some-unqualified-ref'
|
||||
const settings: IGitSourceSettings = inputHelper.getInputs()
|
||||
expect(settings).toBeTruthy()
|
||||
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
|
||||
expect(settings.ref).toBe('refs/heads/some-unqualified-ref')
|
||||
} finally {
|
||||
github.context.ref = originalRef
|
||||
}
|
||||
})
|
||||
|
||||
it('requires qualified repo', () => {
|
||||
inputs.repository = 'some-unqualified-repo'
|
||||
assert.throws(() => {
|
||||
inputHelper.getInputs()
|
||||
}, /Invalid repository 'some-unqualified-repo'/)
|
||||
})
|
||||
|
||||
it('roots path', () => {
|
||||
inputs.path = 'some-directory/some-subdirectory'
|
||||
const settings: IGitSourceSettings = inputHelper.getInputs()
|
||||
expect(settings.repositoryPath).toBe(
|
||||
path.join(gitHubWorkspace, 'some-directory', 'some-subdirectory')
|
||||
)
|
||||
})
|
||||
|
||||
it('sets ref to empty when explicit sha', () => {
|
||||
inputs.ref = '1111111111222222222233333333334444444444'
|
||||
const settings: IGitSourceSettings = inputHelper.getInputs()
|
||||
expect(settings.ref).toBeFalsy()
|
||||
expect(settings.commit).toBe('1111111111222222222233333333334444444444')
|
||||
})
|
||||
|
||||
it('sets sha to empty when explicit ref', () => {
|
||||
inputs.ref = 'refs/heads/some-other-ref'
|
||||
const settings: IGitSourceSettings = inputHelper.getInputs()
|
||||
expect(settings.ref).toBe('refs/heads/some-other-ref')
|
||||
expect(settings.commit).toBeFalsy()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./basic/basic-file.txt" ]; then
|
||||
echo "Expected basic file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo hello >> ./basic/basic-file.txt
|
||||
echo hello >> ./basic/new-file.txt
|
||||
git -C ./basic status
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
mkdir override-git-version
|
||||
cd override-git-version
|
||||
echo @echo override git version 1.2.3 > git.cmd
|
||||
echo ::add-path::%CD%
|
||||
cd ..
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
|
||||
mkdir override-git-version
|
||||
cd override-git-version
|
||||
echo "#!/bin/sh" > git
|
||||
echo "echo override git version 1.2.3" >> git
|
||||
chmod +x git
|
||||
echo "::add-path::$(pwd)"
|
||||
cd ..
|
|
@ -0,0 +1,168 @@
|
|||
import * as assert from 'assert'
|
||||
import * as refHelper from '../lib/ref-helper'
|
||||
import {IGitCommandManager} from '../lib/git-command-manager'
|
||||
|
||||
const commit = '1234567890123456789012345678901234567890'
|
||||
let git: IGitCommandManager
|
||||
|
||||
describe('ref-helper tests', () => {
|
||||
beforeEach(() => {
|
||||
git = ({} as unknown) as IGitCommandManager
|
||||
})
|
||||
|
||||
it('getCheckoutInfo requires git', async () => {
|
||||
const git = (null as unknown) as IGitCommandManager
|
||||
try {
|
||||
await refHelper.getCheckoutInfo(git, 'refs/heads/my/branch', commit)
|
||||
throw new Error('Should not reach here')
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('Arg git cannot be empty')
|
||||
}
|
||||
})
|
||||
|
||||
it('getCheckoutInfo requires ref or commit', async () => {
|
||||
try {
|
||||
await refHelper.getCheckoutInfo(git, '', '')
|
||||
throw new Error('Should not reach here')
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('Args ref and commit cannot both be empty')
|
||||
}
|
||||
})
|
||||
|
||||
it('getCheckoutInfo sha only', async () => {
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(git, '', commit)
|
||||
expect(checkoutInfo.ref).toBe(commit)
|
||||
expect(checkoutInfo.startPoint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('getCheckoutInfo refs/heads/', async () => {
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(
|
||||
git,
|
||||
'refs/heads/my/branch',
|
||||
commit
|
||||
)
|
||||
expect(checkoutInfo.ref).toBe('my/branch')
|
||||
expect(checkoutInfo.startPoint).toBe('refs/remotes/origin/my/branch')
|
||||
})
|
||||
|
||||
it('getCheckoutInfo refs/pull/', async () => {
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(
|
||||
git,
|
||||
'refs/pull/123/merge',
|
||||
commit
|
||||
)
|
||||
expect(checkoutInfo.ref).toBe('refs/remotes/pull/123/merge')
|
||||
expect(checkoutInfo.startPoint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('getCheckoutInfo refs/tags/', async () => {
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(
|
||||
git,
|
||||
'refs/tags/my-tag',
|
||||
commit
|
||||
)
|
||||
expect(checkoutInfo.ref).toBe('refs/tags/my-tag')
|
||||
expect(checkoutInfo.startPoint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('getCheckoutInfo unqualified branch only', async () => {
|
||||
git.branchExists = jest.fn(async (remote: boolean, pattern: string) => {
|
||||
return true
|
||||
})
|
||||
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(git, 'my/branch', '')
|
||||
|
||||
expect(checkoutInfo.ref).toBe('my/branch')
|
||||
expect(checkoutInfo.startPoint).toBe('refs/remotes/origin/my/branch')
|
||||
})
|
||||
|
||||
it('getCheckoutInfo unqualified tag only', async () => {
|
||||
git.branchExists = jest.fn(async (remote: boolean, pattern: string) => {
|
||||
return false
|
||||
})
|
||||
git.tagExists = jest.fn(async (pattern: string) => {
|
||||
return true
|
||||
})
|
||||
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(git, 'my-tag', '')
|
||||
|
||||
expect(checkoutInfo.ref).toBe('refs/tags/my-tag')
|
||||
expect(checkoutInfo.startPoint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('getCheckoutInfo unqualified ref only, not a branch or tag', async () => {
|
||||
git.branchExists = jest.fn(async (remote: boolean, pattern: string) => {
|
||||
return false
|
||||
})
|
||||
git.tagExists = jest.fn(async (pattern: string) => {
|
||||
return false
|
||||
})
|
||||
|
||||
try {
|
||||
await refHelper.getCheckoutInfo(git, 'my-ref', '')
|
||||
throw new Error('Should not reach here')
|
||||
} catch (err) {
|
||||
expect(err.message).toBe(
|
||||
"A branch or tag with the name 'my-ref' could not be found"
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('getRefSpec requires ref or commit', async () => {
|
||||
assert.throws(
|
||||
() => refHelper.getRefSpec('', ''),
|
||||
/Args ref and commit cannot both be empty/
|
||||
)
|
||||
})
|
||||
|
||||
it('getRefSpec sha + refs/heads/', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit)
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe(`+${commit}:refs/remotes/origin/my/branch`)
|
||||
})
|
||||
|
||||
it('getRefSpec sha + refs/pull/', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/pull/123/merge', commit)
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe(`+${commit}:refs/remotes/pull/123/merge`)
|
||||
})
|
||||
|
||||
it('getRefSpec sha + refs/tags/', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe(`+${commit}:refs/tags/my-tag`)
|
||||
})
|
||||
|
||||
it('getRefSpec sha only', async () => {
|
||||
const refSpec = refHelper.getRefSpec('', commit)
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe(commit)
|
||||
})
|
||||
|
||||
it('getRefSpec unqualified ref only', async () => {
|
||||
const refSpec = refHelper.getRefSpec('my-ref', '')
|
||||
expect(refSpec.length).toBe(2)
|
||||
expect(refSpec[0]).toBe('+refs/heads/my-ref*:refs/remotes/origin/my-ref*')
|
||||
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
|
||||
})
|
||||
|
||||
it('getRefSpec refs/heads/ only', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe(
|
||||
'+refs/heads/my/branch:refs/remotes/origin/my/branch'
|
||||
)
|
||||
})
|
||||
|
||||
it('getRefSpec refs/pull/ only', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/pull/123/merge', '')
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe('+refs/pull/123/merge:refs/remotes/pull/123/merge')
|
||||
})
|
||||
|
||||
it('getRefSpec refs/tags/ only', async () => {
|
||||
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '')
|
||||
expect(refSpec.length).toBe(1)
|
||||
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,87 @@
|
|||
import * as core from '@actions/core'
|
||||
import {RetryHelper} from '../lib/retry-helper'
|
||||
|
||||
let info: string[]
|
||||
let retryHelper: any
|
||||
|
||||
describe('retry-helper tests', () => {
|
||||
beforeAll(() => {
|
||||
// Mock @actions/core info()
|
||||
jest.spyOn(core, 'info').mockImplementation((message: string) => {
|
||||
info.push(message)
|
||||
})
|
||||
|
||||
retryHelper = new RetryHelper(3, 0, 0)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset info
|
||||
info = []
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Restore
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('first attempt succeeds', async () => {
|
||||
const actual = await retryHelper.execute(async () => {
|
||||
return 'some result'
|
||||
})
|
||||
expect(actual).toBe('some result')
|
||||
expect(info).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('second attempt succeeds', async () => {
|
||||
let attempts = 0
|
||||
const actual = await retryHelper.execute(() => {
|
||||
if (++attempts == 1) {
|
||||
throw new Error('some error')
|
||||
}
|
||||
|
||||
return Promise.resolve('some result')
|
||||
})
|
||||
expect(attempts).toBe(2)
|
||||
expect(actual).toBe('some result')
|
||||
expect(info).toHaveLength(2)
|
||||
expect(info[0]).toBe('some error')
|
||||
expect(info[1]).toMatch(/Waiting .+ seconds before trying again/)
|
||||
})
|
||||
|
||||
it('third attempt succeeds', async () => {
|
||||
let attempts = 0
|
||||
const actual = await retryHelper.execute(() => {
|
||||
if (++attempts < 3) {
|
||||
throw new Error(`some error ${attempts}`)
|
||||
}
|
||||
|
||||
return Promise.resolve('some result')
|
||||
})
|
||||
expect(attempts).toBe(3)
|
||||
expect(actual).toBe('some result')
|
||||
expect(info).toHaveLength(4)
|
||||
expect(info[0]).toBe('some error 1')
|
||||
expect(info[1]).toMatch(/Waiting .+ seconds before trying again/)
|
||||
expect(info[2]).toBe('some error 2')
|
||||
expect(info[3]).toMatch(/Waiting .+ seconds before trying again/)
|
||||
})
|
||||
|
||||
it('all attempts fail succeeds', async () => {
|
||||
let attempts = 0
|
||||
let error: Error = (null as unknown) as Error
|
||||
try {
|
||||
await retryHelper.execute(() => {
|
||||
throw new Error(`some error ${++attempts}`)
|
||||
})
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
expect(error.message).toBe('some error 3')
|
||||
expect(attempts).toBe(3)
|
||||
expect(info).toHaveLength(4)
|
||||
expect(info[0]).toBe('some error 1')
|
||||
expect(info[1]).toMatch(/Waiting .+ seconds before trying again/)
|
||||
expect(info[2]).toBe('some error 2')
|
||||
expect(info[3]).toMatch(/Waiting .+ seconds before trying again/)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ ! -f "./basic/basic-file.txt" ]; then
|
||||
echo "Expected basic file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$1" = "--archive" ]; then
|
||||
# Verify no .git folder
|
||||
if [ -d "./basic/.git" ]; then
|
||||
echo "Did not expect ./basic/.git folder to exist"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Verify .git folder
|
||||
if [ ! -d "./basic/.git" ]; then
|
||||
echo "Expected ./basic/.git folder to exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify auth token
|
||||
cd basic
|
||||
git fetch --no-tags --depth=1 origin +refs/heads/master:refs/remotes/origin/master
|
||||
fi
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ "$(git -C ./basic status --porcelain)" != "" ]]; then
|
||||
echo ----------------------------------------
|
||||
echo git status
|
||||
echo ----------------------------------------
|
||||
git status
|
||||
echo ----------------------------------------
|
||||
echo git diff
|
||||
echo ----------------------------------------
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./lfs/regular-file.txt" ]; then
|
||||
echo "Expected regular file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "./lfs/lfs-file.bin" ]; then
|
||||
echo "Expected lfs file does not exist"
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ "$(git status --porcelain)" != "" ]]; then
|
||||
echo ----------------------------------------
|
||||
echo git status
|
||||
echo ----------------------------------------
|
||||
git status
|
||||
echo ----------------------------------------
|
||||
echo git diff
|
||||
echo ----------------------------------------
|
||||
git diff
|
||||
echo ----------------------------------------
|
||||
echo Troubleshooting
|
||||
echo ----------------------------------------
|
||||
echo "::error::Unstaged changes detected. Locally try running: git clean -ffdx && npm ci && npm run format && npm run build"
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./side-by-side-1/side-by-side-test-file-1.txt" ]; then
|
||||
echo "Expected file 1 does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "./side-by-side-2/side-by-side-test-file-2.txt" ]; then
|
||||
echo "Expected file 2 does not exist"
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./submodules-false/regular-file.txt" ]; then
|
||||
echo "Expected regular file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "./submodules-false/submodule-level-1/submodule-file.txt" ]; then
|
||||
echo "Unexpected submodule file exists"
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./submodules-recursive/regular-file.txt" ]; then
|
||||
echo "Expected regular file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "./submodules-recursive/submodule-level-1/submodule-file.txt" ]; then
|
||||
echo "Expected submodule file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "./submodules-recursive/submodule-level-1/submodule-level-2/nested-submodule-file.txt" ]; then
|
||||
echo "Expected nested submodule file does not exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Testing persisted credential"
|
||||
pushd ./submodules-recursive/submodule-level-1/submodule-level-2
|
||||
git config --local --name-only --get-regexp http.+extraheader && git fetch
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "Failed to validate persisted credential"
|
||||
popd
|
||||
exit 1
|
||||
fi
|
||||
popd
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ ! -f "./submodules-true/regular-file.txt" ]; then
|
||||
echo "Expected regular file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "./submodules-true/submodule-level-1/submodule-file.txt" ]; then
|
||||
echo "Expected submodule file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "./submodules-true/submodule-level-1/submodule-level-2/nested-submodule-file.txt" ]; then
|
||||
echo "Unexpected nested submodule file exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Testing persisted credential"
|
||||
pushd ./submodules-true/submodule-level-1
|
||||
git config --local --name-only --get-regexp http.+extraheader && git fetch
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "Failed to validate persisted credential"
|
||||
popd
|
||||
exit 1
|
||||
fi
|
||||
popd
|
|
@ -0,0 +1,74 @@
|
|||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: 'Repository name with owner. For example, actions/checkout'
|
||||
default: ${{ github.repository }}
|
||||
ref:
|
||||
description: >
|
||||
The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that
|
||||
event. Otherwise, uses the default branch.
|
||||
token:
|
||||
description: >
|
||||
Personal access token (PAT) used to fetch the repository. The PAT is configured
|
||||
with the local git config, which enables your scripts to run authenticated git
|
||||
commands. The post-job step removes the PAT.
|
||||
|
||||
|
||||
We recommend using a service account with the least permissions necessary.
|
||||
Also when generating a new PAT, select the least scopes necessary.
|
||||
|
||||
|
||||
[Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
default: ${{ github.token }}
|
||||
ssh-key:
|
||||
description: >
|
||||
SSH key used to fetch the repository. The SSH key is configured with the local
|
||||
git config, which enables your scripts to run authenticated git commands.
|
||||
The post-job step removes the SSH key.
|
||||
|
||||
|
||||
We recommend using a service account with the least permissions necessary.
|
||||
|
||||
|
||||
[Learn more about creating and using
|
||||
encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
ssh-known-hosts:
|
||||
description: >
|
||||
Known hosts in addition to the user and global host key database. The public
|
||||
SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example,
|
||||
`ssh-keyscan github.com`. The public key for github.com is always implicitly added.
|
||||
ssh-strict:
|
||||
description: >
|
||||
Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes`
|
||||
and `CheckHostIP=no` to the SSH command line. Use the input `ssh-known-hosts` to
|
||||
configure additional hosts.
|
||||
default: true
|
||||
persist-credentials:
|
||||
description: 'Whether to configure the token or SSH key with the local git config'
|
||||
default: true
|
||||
path:
|
||||
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
|
||||
clean:
|
||||
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
|
||||
default: true
|
||||
fetch-depth:
|
||||
description: 'Number of commits to fetch. 0 indicates all history for all branches and tags.'
|
||||
default: 1
|
||||
lfs:
|
||||
description: 'Whether to download Git-LFS files'
|
||||
default: false
|
||||
submodules:
|
||||
description: >
|
||||
Whether to checkout submodules: `true` to checkout submodules or `recursive` to
|
||||
recursively checkout submodules.
|
||||
|
||||
|
||||
When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are
|
||||
converted to HTTPS.
|
||||
default: false
|
||||
runs:
|
||||
using: node12
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
|
@ -0,0 +1,290 @@
|
|||
# ADR 0153: Checkout v2
|
||||
|
||||
**Date**: 2019-10-21
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
This ADR details the behavior for `actions/checkout@v2`.
|
||||
|
||||
The new action will be written in typescript. We are moving away from runner-plugin actions.
|
||||
|
||||
We want to take this opportunity to make behavioral changes, from v1. This document is scoped to those differences.
|
||||
|
||||
## Decision
|
||||
|
||||
### Inputs
|
||||
|
||||
```yaml
|
||||
repository:
|
||||
description: 'Repository name with owner. For example, actions/checkout'
|
||||
default: ${{ github.repository }}
|
||||
ref:
|
||||
description: >
|
||||
The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that
|
||||
event. Otherwise, defaults to `master`.
|
||||
token:
|
||||
description: >
|
||||
Personal access token (PAT) used to fetch the repository. The PAT is configured
|
||||
with the local git config, which enables your scripts to run authenticated git
|
||||
commands. The post-job step removes the PAT.
|
||||
|
||||
|
||||
We recommend using a service account with the least permissions necessary.
|
||||
Also when generating a new PAT, select the least scopes necessary.
|
||||
|
||||
|
||||
[Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
default: ${{ github.token }}
|
||||
ssh-key:
|
||||
description: >
|
||||
SSH key used to fetch the repository. The SSH key is configured with the local
|
||||
git config, which enables your scripts to run authenticated git commands.
|
||||
The post-job step removes the SSH key.
|
||||
|
||||
|
||||
We recommend using a service account with the least permissions necessary.
|
||||
|
||||
|
||||
[Learn more about creating and using
|
||||
encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
ssh-known-hosts:
|
||||
description: >
|
||||
Known hosts in addition to the user and global host key database. The public
|
||||
SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example,
|
||||
`ssh-keyscan github.com`. The public key for github.com is always implicitly added.
|
||||
ssh-strict:
|
||||
description: >
|
||||
Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes`
|
||||
and `CheckHostIP=no` to the SSH command line. Use the input `ssh-known-hosts` to
|
||||
configure additional hosts.
|
||||
default: true
|
||||
persist-credentials:
|
||||
description: 'Whether to configure the token or SSH key with the local git config'
|
||||
default: true
|
||||
path:
|
||||
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
|
||||
clean:
|
||||
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
|
||||
default: true
|
||||
fetch-depth:
|
||||
description: 'Number of commits to fetch. 0 indicates all history for all tags and branches.'
|
||||
default: 1
|
||||
lfs:
|
||||
description: 'Whether to download Git-LFS files'
|
||||
default: false
|
||||
submodules:
|
||||
description: >
|
||||
Whether to checkout submodules: `true` to checkout submodules or `recursive` to
|
||||
recursively checkout submodules.
|
||||
|
||||
|
||||
When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are
|
||||
converted to HTTPS.
|
||||
default: false
|
||||
```
|
||||
|
||||
Note:
|
||||
- SSH support is new
|
||||
- `persist-credentials` is new
|
||||
- `path` behavior is different (refer [below](#path) for details)
|
||||
|
||||
### Fallback to GitHub API
|
||||
|
||||
When a sufficient version of git is not in the PATH, fallback to the [web API](https://developer.github.com/v3/repos/contents/#get-archive-link) to download a tarball/zipball.
|
||||
|
||||
Note:
|
||||
- LFS files are not included in the archive. Therefore fail if LFS is set to true.
|
||||
- Submodules are also not included in the archive.
|
||||
|
||||
### Persist credentials
|
||||
|
||||
The credentials will be persisted on disk. This will allow users to script authenticated git commands, like `git fetch`.
|
||||
|
||||
A post script will remove the credentials (cleanup for self-hosted).
|
||||
|
||||
Users may opt-out by specifying `persist-credentials: false`
|
||||
|
||||
Note:
|
||||
- Users scripting `git commit` may need to set the username and email. The service does not provide any reasonable default value. Users can add `git config user.name <NAME>` and `git config user.email <EMAIL>`. We will document this guidance.
|
||||
|
||||
#### PAT
|
||||
|
||||
When using the `${{github.token}}` or a PAT, the token will be persisted in the local git config. The config key `http.https://github.com/.extraheader` enables an auth header to be specified on all authenticated commands `AUTHORIZATION: basic <BASE64_U:P>`.
|
||||
|
||||
Note:
|
||||
- The auth header is scoped to all of github `http.https://github.com/.extraheader`
|
||||
- Additional public remotes also just work.
|
||||
- If users want to authenticate to an additional private remote, they should provide the `token` input.
|
||||
|
||||
#### SSH key
|
||||
|
||||
The SSH key will be written to disk under the `$RUNNER_TEMP` directory. The SSH key will
|
||||
be removed by the action's post-job hook. Additionally, RUNNER_TEMP is cleared by the
|
||||
runner between jobs.
|
||||
|
||||
The SSH key must be written with strict file permissions. The SSH client requires the file
|
||||
to be read/write for the user, and not accessible by others.
|
||||
|
||||
The user host key database (`~/.ssh/known_hosts`) will be copied to a unique file under
|
||||
`$RUNNER_TEMP`. And values from the input `ssh-known-hosts` will be added to the file.
|
||||
|
||||
The SSH command will be overridden for the local git config:
|
||||
|
||||
```sh
|
||||
git config core.sshCommand 'ssh -i "$RUNNER_TEMP/path-to-ssh-key" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/path-to-known-hosts"'
|
||||
```
|
||||
|
||||
When the input `ssh-strict` is set to `false`, the options `CheckHostIP` and `StrictHostKeyChecking` will not be overridden.
|
||||
|
||||
Note:
|
||||
- When `ssh-strict` is set to `true` (default), the SSH option `CheckHostIP` can safely be disabled.
|
||||
Strict host checking verifies the server's public key. Therefore, IP verification is unnecessary
|
||||
and noisy. For example:
|
||||
> Warning: Permanently added the RSA host key for IP address '140.82.113.4' to the list of known hosts.
|
||||
- Since GIT_SSH_COMMAND overrides core.sshCommand, temporarily set the env var when fetching the repo. When creds
|
||||
are persisted, core.sshCommand is leveraged to avoid multiple checkout steps stomping over each other.
|
||||
- Modify actions/runner to mount RUNNER_TEMP to enable scripting authenticated git commands from a container action.
|
||||
- Refer [here](https://linux.die.net/man/5/ssh_config) for SSH config details.
|
||||
|
||||
### Fetch behavior
|
||||
|
||||
Fetch only the SHA being built and set depth=1. This significantly reduces the fetch time for large repos.
|
||||
|
||||
If a SHA isn't available (e.g. multi repo), then fetch only the specified ref with depth=1.
|
||||
|
||||
The input `fetch-depth` can be used to control the depth.
|
||||
|
||||
Note:
|
||||
- Fetching a single commit is supported by Git wire protocol version 2. The git client uses protocol version 0 by default. The desired protocol version can be overridden in the git config or on the fetch command line invocation (`-c protocol.version=2`). We will override on the fetch command line, for transparency.
|
||||
- Git client version 2.18+ (released June 2018) is required for wire protocol version 2.
|
||||
|
||||
### Checkout behavior
|
||||
|
||||
For CI, checkout will create a local ref with the upstream set. This allows users to script git as they normally would.
|
||||
|
||||
For PR, continue to checkout detached head. The PR branch is special - the branch and merge commit are created by the server. It doesn't match a users' local workflow.
|
||||
|
||||
Note:
|
||||
- Consider deleting all local refs during cleanup if that helps avoid collisions. More testing required.
|
||||
|
||||
### Path
|
||||
|
||||
For the mainline scenario, the disk-layout behavior remains the same.
|
||||
|
||||
Remember, given the repo `johndoe/foo`, the mainline disk layout looks like:
|
||||
|
||||
```
|
||||
GITHUB_WORKSPACE=/home/runner/work/foo/foo
|
||||
RUNNER_WORKSPACE=/home/runner/work/foo
|
||||
```
|
||||
|
||||
V2 introduces a new contraint on the checkout path. The location must now be under `github.workspace`. Whereas the checkout@v1 constraint was one level up, under `runner.workspace`.
|
||||
|
||||
V2 no longer changes `github.workspace` to follow wherever the self repo is checked-out.
|
||||
|
||||
These behavioral changes align better with container actions. The [documented filesystem contract](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/virtual-environments-for-github-hosted-runners#docker-container-filesystem) is:
|
||||
|
||||
- `/github/home`
|
||||
- `/github/workspace` - Note: GitHub Actions must be run by the default Docker user (root). Ensure your Dockerfile does not set the USER instruction, otherwise you will not be able to access `GITHUB_WORKSPACE`.
|
||||
- `/github/workflow`
|
||||
|
||||
Note:
|
||||
- The tracking config will not be updated to reflect the path of the workflow repo.
|
||||
- Any existing workflow repo will not be moved when the checkout path changes. In fact some customers want to checkout the workflow repo twice, side by side against different branches.
|
||||
- Actions that need to operate only against the root of the self repo, should expose a `path` input.
|
||||
|
||||
#### Default value for `path` input
|
||||
|
||||
The `path` input will default to `./` which is rooted against `github.workspace`.
|
||||
|
||||
This default fits the mainline scenario well: single checkout
|
||||
|
||||
For multi-checkout, users must specify the `path` input for at least one of the repositories.
|
||||
|
||||
Note:
|
||||
- An alternative is for the self repo to default to `./` and other repos default to `<REPO_NAME>`. However nested layout is an atypical git layout and therefore is not a good default. Users should supply the path info.
|
||||
|
||||
#### Example - Nested layout
|
||||
|
||||
The following example checks-out two repositories and creates a nested layout.
|
||||
|
||||
```yaml
|
||||
# Self repo - Checkout to $GITHUB_WORKSPACE
|
||||
- uses: checkout@v2
|
||||
|
||||
# Other repo - Checkout to $GITHUB_WORKSPACE/myscripts
|
||||
- uses: checkout@v2
|
||||
with:
|
||||
repository: myorg/myscripts
|
||||
path: myscripts
|
||||
```
|
||||
|
||||
#### Example - Side by side layout
|
||||
|
||||
The following example checks-out two repositories and creates a side-by-side layout.
|
||||
|
||||
```yaml
|
||||
# Self repo - Checkout to $GITHUB_WORKSPACE/foo
|
||||
- uses: checkout@v2
|
||||
with:
|
||||
path: foo
|
||||
|
||||
# Other repo - Checkout to $GITHUB_WORKSPACE/myscripts
|
||||
- uses: checkout@v2
|
||||
with:
|
||||
repository: myorg/myscripts
|
||||
path: myscripts
|
||||
```
|
||||
|
||||
#### Path impact to problem matchers
|
||||
|
||||
Problem matchers associate the source files with annotations.
|
||||
|
||||
Today the runner verifies the source file is under the `github.workspace`. Otherwise the source file property is dropped.
|
||||
|
||||
Multi-checkout complicates the matter. However even today submodules may cause this heuristic to be inaccurate.
|
||||
|
||||
A better solution is:
|
||||
|
||||
Given a source file path, walk up the directories until the first `.git/config` is found. Check if it matches the self repo (`url = https://github.com/OWNER/REPO`). If not, drop the source file path.
|
||||
|
||||
### Submodules
|
||||
|
||||
With both PAT and SSH key support, we should be able to provide frictionless support for
|
||||
submodules scenarios: recursive, non-recursive, relative submodule paths.
|
||||
|
||||
When fetching submodules, follow the `fetch-depth` settings.
|
||||
|
||||
Also when fetching submodules, if the `ssh-key` input is not provided then convert SSH URLs to HTTPS: `-c url."https://github.com/".insteadOf "git@github.com:"`
|
||||
|
||||
Credentials will be persisted in the submodules local git config too.
|
||||
|
||||
### Port to typescript
|
||||
|
||||
The checkout action should be a typescript action on the GitHub graph, for the following reasons:
|
||||
- Enables customers to fork the checkout repo and modify
|
||||
- Serves as an example for customers
|
||||
- Demystifies the checkout action manifest
|
||||
- Simplifies the runner
|
||||
- Reduce the amount of runner code to port (if we ever do)
|
||||
|
||||
Note:
|
||||
- This means job-container images will need git in the PATH, for checkout.
|
||||
|
||||
### Branching strategy and release tags
|
||||
|
||||
- Create a servicing branch for V1: `releases/v1`
|
||||
- Merge the changes into `master`
|
||||
- Release using a new tag `preview`
|
||||
- When stable, release using a new tag `v2`
|
||||
|
||||
## Consequences
|
||||
|
||||
- Update the checkout action and readme
|
||||
- Update samples to consume `actions/checkout@v2`
|
||||
- Job containers now require git in the PATH for checkout, otherwise fallback to REST API
|
||||
- Minimum git version 2.18
|
||||
- Update problem matcher logic regarding source file verification (runner)
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "checkout-git",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(fatal|error): (.*)$",
|
||||
"message": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
clearMocks: true,
|
||||
moduleFileExtensions: ['js', 'ts'],
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
testRunner: 'jest-circus/runner',
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest'
|
||||
},
|
||||
verbose: true
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "checkout",
|
||||
"version": "2.0.2",
|
||||
"description": "checkout action",
|
||||
"main": "lib/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc && ncc build && node lib/misc/generate-docs.js",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actions/checkout.git"
|
||||
},
|
||||
"keywords": [
|
||||
"github",
|
||||
"actions",
|
||||
"checkout"
|
||||
],
|
||||
"author": "GitHub",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/checkout/issues"
|
||||
},
|
||||
"homepage": "https://github.com/actions/checkout#readme",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.1.3",
|
||||
"@actions/exec": "^1.0.1",
|
||||
"@actions/github": "^2.2.0",
|
||||
"@actions/io": "^1.0.1",
|
||||
"@actions/tool-cache": "^1.1.2",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^24.0.23",
|
||||
"@types/node": "^12.7.12",
|
||||
"@types/uuid": "^3.4.6",
|
||||
"@typescript-eslint/parser": "^2.8.0",
|
||||
"@zeit/ncc": "^0.20.5",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"eslint-plugin-jest": "^22.21.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-circus": "^24.9.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"prettier": "^1.19.1",
|
||||
"ts-jest": "^24.2.0",
|
||||
"typescript": "^3.6.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import * as fs from 'fs'
|
||||
|
||||
export function directoryExistsSync(path: string, required?: boolean): boolean {
|
||||
if (!path) {
|
||||
throw new Error("Arg 'path' must not be empty")
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(path)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
if (!required) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(`Directory '${path}' does not exist`)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Encountered an error when checking whether path '${path}' exists: ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
return true
|
||||
} else if (!required) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(`Directory '${path}' does not exist`)
|
||||
}
|
||||
|
||||
export function existsSync(path: string): boolean {
|
||||
if (!path) {
|
||||
throw new Error("Arg 'path' must not be empty")
|
||||
}
|
||||
|
||||
try {
|
||||
fs.statSync(path)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Encountered an error when checking whether path '${path}' exists: ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function fileExistsSync(path: string): boolean {
|
||||
if (!path) {
|
||||
throw new Error("Arg 'path' must not be empty")
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(path)
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Encountered an error when checking whether path '${path}' exists: ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
import * as assert from 'assert'
|
||||
import * as core from '@actions/core'
|
||||
import * as exec from '@actions/exec'
|
||||
import * as fs from 'fs'
|
||||
import * as io from '@actions/io'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as regexpHelper from './regexp-helper'
|
||||
import * as stateHelper from './state-helper'
|
||||
import * as urlHelper from './url-helper'
|
||||
import {default as uuid} from 'uuid/v4'
|
||||
import {IGitCommandManager} from './git-command-manager'
|
||||
import {IGitSourceSettings} from './git-source-settings'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const SSH_COMMAND_KEY = 'core.sshCommand'
|
||||
|
||||
export interface IGitAuthHelper {
|
||||
configureAuth(): Promise<void>
|
||||
configureGlobalAuth(): Promise<void>
|
||||
configureSubmoduleAuth(): Promise<void>
|
||||
removeAuth(): Promise<void>
|
||||
removeGlobalAuth(): Promise<void>
|
||||
}
|
||||
|
||||
export function createAuthHelper(
|
||||
git: IGitCommandManager,
|
||||
settings?: IGitSourceSettings
|
||||
): IGitAuthHelper {
|
||||
return new GitAuthHelper(git, settings)
|
||||
}
|
||||
|
||||
class GitAuthHelper {
|
||||
private readonly git: IGitCommandManager
|
||||
private readonly settings: IGitSourceSettings
|
||||
private readonly tokenConfigKey: string
|
||||
private readonly tokenConfigValue: string
|
||||
private readonly tokenPlaceholderConfigValue: string
|
||||
private readonly insteadOfKey: string
|
||||
private readonly insteadOfValue: string
|
||||
private sshCommand = ''
|
||||
private sshKeyPath = ''
|
||||
private sshKnownHostsPath = ''
|
||||
private temporaryHomePath = ''
|
||||
|
||||
constructor(
|
||||
gitCommandManager: IGitCommandManager,
|
||||
gitSourceSettings?: IGitSourceSettings
|
||||
) {
|
||||
this.git = gitCommandManager
|
||||
this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings)
|
||||
|
||||
// Token auth header
|
||||
const serverUrl = urlHelper.getServerUrl()
|
||||
this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT]
|
||||
const basicCredential = Buffer.from(
|
||||
`x-access-token:${this.settings.authToken}`,
|
||||
'utf8'
|
||||
).toString('base64')
|
||||
core.setSecret(basicCredential)
|
||||
this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`
|
||||
this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`
|
||||
|
||||
// Instead of SSH URL
|
||||
this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT]
|
||||
this.insteadOfValue = `git@${serverUrl.hostname}:`
|
||||
}
|
||||
|
||||
async configureAuth(): Promise<void> {
|
||||
// Remove possible previous values
|
||||
await this.removeAuth()
|
||||
|
||||
// Configure new values
|
||||
await this.configureSsh()
|
||||
await this.configureToken()
|
||||
}
|
||||
|
||||
async configureGlobalAuth(): Promise<void> {
|
||||
// Create a temp home directory
|
||||
const runnerTemp = process.env['RUNNER_TEMP'] || ''
|
||||
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
|
||||
const uniqueId = uuid()
|
||||
this.temporaryHomePath = path.join(runnerTemp, uniqueId)
|
||||
await fs.promises.mkdir(this.temporaryHomePath, {recursive: true})
|
||||
|
||||
// Copy the global git config
|
||||
const gitConfigPath = path.join(
|
||||
process.env['HOME'] || os.homedir(),
|
||||
'.gitconfig'
|
||||
)
|
||||
const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig')
|
||||
let configExists = false
|
||||
try {
|
||||
await fs.promises.stat(gitConfigPath)
|
||||
configExists = true
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (configExists) {
|
||||
core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`)
|
||||
await io.cp(gitConfigPath, newGitConfigPath)
|
||||
} else {
|
||||
await fs.promises.writeFile(newGitConfigPath, '')
|
||||
}
|
||||
|
||||
try {
|
||||
// Override HOME
|
||||
core.info(
|
||||
`Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`
|
||||
)
|
||||
this.git.setEnvironmentVariable('HOME', this.temporaryHomePath)
|
||||
|
||||
// Configure the token
|
||||
await this.configureToken(newGitConfigPath, true)
|
||||
|
||||
// Configure HTTPS instead of SSH
|
||||
await this.git.tryConfigUnset(this.insteadOfKey, true)
|
||||
if (!this.settings.sshKey) {
|
||||
await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
|
||||
}
|
||||
} catch (err) {
|
||||
// Unset in case somehow written to the real global config
|
||||
core.info(
|
||||
'Encountered an error when attempting to configure token. Attempting unconfigure.'
|
||||
)
|
||||
await this.git.tryConfigUnset(this.tokenConfigKey, true)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async configureSubmoduleAuth(): Promise<void> {
|
||||
// Remove possible previous HTTPS instead of SSH
|
||||
await this.removeGitConfig(this.insteadOfKey, true)
|
||||
|
||||
if (this.settings.persistCredentials) {
|
||||
// Configure a placeholder value. This approach avoids the credential being captured
|
||||
// by process creation audit events, which are commonly logged. For more information,
|
||||
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||
const output = await this.git.submoduleForeach(
|
||||
`git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
|
||||
this.settings.nestedSubmodules
|
||||
)
|
||||
|
||||
// Replace the placeholder
|
||||
const configPaths: string[] =
|
||||
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
|
||||
for (const configPath of configPaths) {
|
||||
core.debug(`Replacing token placeholder in '${configPath}'`)
|
||||
this.replaceTokenPlaceholder(configPath)
|
||||
}
|
||||
|
||||
if (this.settings.sshKey) {
|
||||
// Configure core.sshCommand
|
||||
await this.git.submoduleForeach(
|
||||
`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
|
||||
this.settings.nestedSubmodules
|
||||
)
|
||||
} else {
|
||||
// Configure HTTPS instead of SSH
|
||||
await this.git.submoduleForeach(
|
||||
`git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`,
|
||||
this.settings.nestedSubmodules
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async removeAuth(): Promise<void> {
|
||||
await this.removeSsh()
|
||||
await this.removeToken()
|
||||
}
|
||||
|
||||
async removeGlobalAuth(): Promise<void> {
|
||||
core.debug(`Unsetting HOME override`)
|
||||
this.git.removeEnvironmentVariable('HOME')
|
||||
await io.rmRF(this.temporaryHomePath)
|
||||
}
|
||||
|
||||
private async configureSsh(): Promise<void> {
|
||||
if (!this.settings.sshKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Write key
|
||||
const runnerTemp = process.env['RUNNER_TEMP'] || ''
|
||||
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
|
||||
const uniqueId = uuid()
|
||||
this.sshKeyPath = path.join(runnerTemp, uniqueId)
|
||||
stateHelper.setSshKeyPath(this.sshKeyPath)
|
||||
await fs.promises.mkdir(runnerTemp, {recursive: true})
|
||||
await fs.promises.writeFile(
|
||||
this.sshKeyPath,
|
||||
this.settings.sshKey.trim() + '\n',
|
||||
{mode: 0o600}
|
||||
)
|
||||
|
||||
// Remove inherited permissions on Windows
|
||||
if (IS_WINDOWS) {
|
||||
const icacls = await io.which('icacls.exe')
|
||||
await exec.exec(
|
||||
`"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
|
||||
)
|
||||
await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
|
||||
}
|
||||
|
||||
// Write known hosts
|
||||
const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
|
||||
let userKnownHosts = ''
|
||||
try {
|
||||
userKnownHosts = (
|
||||
await fs.promises.readFile(userKnownHostsPath)
|
||||
).toString()
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
let knownHosts = ''
|
||||
if (userKnownHosts) {
|
||||
knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
|
||||
}
|
||||
if (this.settings.sshKnownHosts) {
|
||||
knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
|
||||
}
|
||||
knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n# End implicitly added github.com\n`
|
||||
this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
|
||||
stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
|
||||
await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
|
||||
|
||||
// Configure GIT_SSH_COMMAND
|
||||
const sshPath = await io.which('ssh', true)
|
||||
this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
|
||||
this.sshKeyPath
|
||||
)}"`
|
||||
if (this.settings.sshStrict) {
|
||||
this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
|
||||
}
|
||||
this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
|
||||
this.sshKnownHostsPath
|
||||
)}"`
|
||||
core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
|
||||
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
|
||||
|
||||
// Configure core.sshCommand
|
||||
if (this.settings.persistCredentials) {
|
||||
await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
|
||||
}
|
||||
}
|
||||
|
||||
private async configureToken(
|
||||
configPath?: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<void> {
|
||||
// Validate args
|
||||
assert.ok(
|
||||
(configPath && globalConfig) || (!configPath && !globalConfig),
|
||||
'Unexpected configureToken parameter combinations'
|
||||
)
|
||||
|
||||
// Default config path
|
||||
if (!configPath && !globalConfig) {
|
||||
configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
|
||||
}
|
||||
|
||||
// Configure a placeholder value. This approach avoids the credential being captured
|
||||
// by process creation audit events, which are commonly logged. For more information,
|
||||
// refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
|
||||
await this.git.config(
|
||||
this.tokenConfigKey,
|
||||
this.tokenPlaceholderConfigValue,
|
||||
globalConfig
|
||||
)
|
||||
|
||||
// Replace the placeholder
|
||||
await this.replaceTokenPlaceholder(configPath || '')
|
||||
}
|
||||
|
||||
private async replaceTokenPlaceholder(configPath: string): Promise<void> {
|
||||
assert.ok(configPath, 'configPath is not defined')
|
||||
let content = (await fs.promises.readFile(configPath)).toString()
|
||||
const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
|
||||
if (
|
||||
placeholderIndex < 0 ||
|
||||
placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
|
||||
) {
|
||||
throw new Error(`Unable to replace auth placeholder in ${configPath}`)
|
||||
}
|
||||
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
|
||||
content = content.replace(
|
||||
this.tokenPlaceholderConfigValue,
|
||||
this.tokenConfigValue
|
||||
)
|
||||
await fs.promises.writeFile(configPath, content)
|
||||
}
|
||||
|
||||
private async removeSsh(): Promise<void> {
|
||||
// SSH key
|
||||
const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
|
||||
if (keyPath) {
|
||||
try {
|
||||
await io.rmRF(keyPath)
|
||||
} catch (err) {
|
||||
core.debug(err.message)
|
||||
core.warning(`Failed to remove SSH key '${keyPath}'`)
|
||||
}
|
||||
}
|
||||
|
||||
// SSH known hosts
|
||||
const knownHostsPath =
|
||||
this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
|
||||
if (knownHostsPath) {
|
||||
try {
|
||||
await io.rmRF(knownHostsPath)
|
||||
} catch {
|
||||
// Intentionally empty
|
||||
}
|
||||
}
|
||||
|
||||
// SSH command
|
||||
await this.removeGitConfig(SSH_COMMAND_KEY)
|
||||
}
|
||||
|
||||
private async removeToken(): Promise<void> {
|
||||
// HTTP extra header
|
||||
await this.removeGitConfig(this.tokenConfigKey)
|
||||
}
|
||||
|
||||
private async removeGitConfig(
|
||||
configKey: string,
|
||||
submoduleOnly: boolean = false
|
||||
): Promise<void> {
|
||||
if (!submoduleOnly) {
|
||||
if (
|
||||
(await this.git.configExists(configKey)) &&
|
||||
!(await this.git.tryConfigUnset(configKey))
|
||||
) {
|
||||
// Load the config contents
|
||||
core.warning(`Failed to remove '${configKey}' from the git config`)
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = regexpHelper.escape(configKey)
|
||||
await this.git.submoduleForeach(
|
||||
`git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,500 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as exec from '@actions/exec'
|
||||
import * as fshelper from './fs-helper'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import * as refHelper from './ref-helper'
|
||||
import * as regexpHelper from './regexp-helper'
|
||||
import * as retryHelper from './retry-helper'
|
||||
import {GitVersion} from './git-version'
|
||||
|
||||
// Auth header not supported before 2.9
|
||||
// Wire protocol v2 not supported before 2.18
|
||||
export const MinimumGitVersion = new GitVersion('2.18')
|
||||
|
||||
export interface IGitCommandManager {
|
||||
branchDelete(remote: boolean, branch: string): Promise<void>
|
||||
branchExists(remote: boolean, pattern: string): Promise<boolean>
|
||||
branchList(remote: boolean): Promise<string[]>
|
||||
checkout(ref: string, startPoint: string): Promise<void>
|
||||
checkoutDetach(): Promise<void>
|
||||
config(
|
||||
configKey: string,
|
||||
configValue: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<void>
|
||||
configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
|
||||
fetch(refSpec: string[], fetchDepth?: number): Promise<void>
|
||||
getDefaultBranch(repositoryUrl: string): Promise<string>
|
||||
getWorkingDirectory(): string
|
||||
init(): Promise<void>
|
||||
isDetached(): Promise<boolean>
|
||||
lfsFetch(ref: string): Promise<void>
|
||||
lfsInstall(): Promise<void>
|
||||
log1(): Promise<string>
|
||||
remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
|
||||
removeEnvironmentVariable(name: string): void
|
||||
revParse(ref: string): Promise<string>
|
||||
setEnvironmentVariable(name: string, value: string): void
|
||||
shaExists(sha: string): Promise<boolean>
|
||||
submoduleForeach(command: string, recursive: boolean): Promise<string>
|
||||
submoduleSync(recursive: boolean): Promise<void>
|
||||
submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void>
|
||||
tagExists(pattern: string): Promise<boolean>
|
||||
tryClean(): Promise<boolean>
|
||||
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
|
||||
tryDisableAutomaticGarbageCollection(): Promise<boolean>
|
||||
tryGetFetchUrl(): Promise<string>
|
||||
tryReset(): Promise<boolean>
|
||||
}
|
||||
|
||||
export async function createCommandManager(
|
||||
workingDirectory: string,
|
||||
lfs: boolean
|
||||
): Promise<IGitCommandManager> {
|
||||
return await GitCommandManager.createCommandManager(workingDirectory, lfs)
|
||||
}
|
||||
|
||||
class GitCommandManager {
|
||||
private gitEnv = {
|
||||
GIT_TERMINAL_PROMPT: '0', // Disable git prompt
|
||||
GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager
|
||||
}
|
||||
private gitPath = ''
|
||||
private lfs = false
|
||||
private workingDirectory = ''
|
||||
|
||||
// Private constructor; use createCommandManager()
|
||||
private constructor() {}
|
||||
|
||||
async branchDelete(remote: boolean, branch: string): Promise<void> {
|
||||
const args = ['branch', '--delete', '--force']
|
||||
if (remote) {
|
||||
args.push('--remote')
|
||||
}
|
||||
args.push(branch)
|
||||
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async branchExists(remote: boolean, pattern: string): Promise<boolean> {
|
||||
const args = ['branch', '--list']
|
||||
if (remote) {
|
||||
args.push('--remote')
|
||||
}
|
||||
args.push(pattern)
|
||||
|
||||
const output = await this.execGit(args)
|
||||
return !!output.stdout.trim()
|
||||
}
|
||||
|
||||
async branchList(remote: boolean): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
|
||||
// Note, this implementation uses "rev-parse --symbolic-full-name" because the output from
|
||||
// "branch --list" is more difficult when in a detached HEAD state.
|
||||
// Note, this implementation uses "rev-parse --symbolic-full-name" because there is a bug
|
||||
// in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names.
|
||||
|
||||
const args = ['rev-parse', '--symbolic-full-name']
|
||||
if (remote) {
|
||||
args.push('--remotes=origin')
|
||||
} else {
|
||||
args.push('--branches')
|
||||
}
|
||||
|
||||
const output = await this.execGit(args)
|
||||
|
||||
for (let branch of output.stdout.trim().split('\n')) {
|
||||
branch = branch.trim()
|
||||
if (branch) {
|
||||
if (branch.startsWith('refs/heads/')) {
|
||||
branch = branch.substr('refs/heads/'.length)
|
||||
} else if (branch.startsWith('refs/remotes/')) {
|
||||
branch = branch.substr('refs/remotes/'.length)
|
||||
}
|
||||
|
||||
result.push(branch)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async checkout(ref: string, startPoint: string): Promise<void> {
|
||||
const args = ['checkout', '--progress', '--force']
|
||||
if (startPoint) {
|
||||
args.push('-B', ref, startPoint)
|
||||
} else {
|
||||
args.push(ref)
|
||||
}
|
||||
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async checkoutDetach(): Promise<void> {
|
||||
const args = ['checkout', '--detach']
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async config(
|
||||
configKey: string,
|
||||
configValue: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<void> {
|
||||
await this.execGit([
|
||||
'config',
|
||||
globalConfig ? '--global' : '--local',
|
||||
configKey,
|
||||
configValue
|
||||
])
|
||||
}
|
||||
|
||||
async configExists(
|
||||
configKey: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<boolean> {
|
||||
const pattern = regexpHelper.escape(configKey)
|
||||
const output = await this.execGit(
|
||||
[
|
||||
'config',
|
||||
globalConfig ? '--global' : '--local',
|
||||
'--name-only',
|
||||
'--get-regexp',
|
||||
pattern
|
||||
],
|
||||
true
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
async fetch(refSpec: string[], fetchDepth?: number): Promise<void> {
|
||||
const args = ['-c', 'protocol.version=2', 'fetch']
|
||||
if (!refSpec.some(x => x === refHelper.tagsRefSpec)) {
|
||||
args.push('--no-tags')
|
||||
}
|
||||
|
||||
args.push('--prune', '--progress', '--no-recurse-submodules')
|
||||
if (fetchDepth && fetchDepth > 0) {
|
||||
args.push(`--depth=${fetchDepth}`)
|
||||
} else if (
|
||||
fshelper.fileExistsSync(
|
||||
path.join(this.workingDirectory, '.git', 'shallow')
|
||||
)
|
||||
) {
|
||||
args.push('--unshallow')
|
||||
}
|
||||
|
||||
args.push('origin')
|
||||
for (const arg of refSpec) {
|
||||
args.push(arg)
|
||||
}
|
||||
|
||||
const that = this
|
||||
await retryHelper.execute(async () => {
|
||||
await that.execGit(args)
|
||||
})
|
||||
}
|
||||
|
||||
async getDefaultBranch(repositoryUrl: string): Promise<string> {
|
||||
let output: GitOutput | undefined
|
||||
await retryHelper.execute(async () => {
|
||||
output = await this.execGit([
|
||||
'ls-remote',
|
||||
'--quiet',
|
||||
'--exit-code',
|
||||
'--symref',
|
||||
repositoryUrl,
|
||||
'HEAD'
|
||||
])
|
||||
})
|
||||
|
||||
if (output) {
|
||||
// Satisfy compiler, will always be set
|
||||
for (let line of output.stdout.trim().split('\n')) {
|
||||
line = line.trim()
|
||||
if (line.startsWith('ref:') || line.endsWith('HEAD')) {
|
||||
return line
|
||||
.substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length)
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unexpected output when retrieving default branch')
|
||||
}
|
||||
|
||||
getWorkingDirectory(): string {
|
||||
return this.workingDirectory
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.execGit(['init', this.workingDirectory])
|
||||
}
|
||||
|
||||
async isDetached(): Promise<boolean> {
|
||||
// Note, "branch --show-current" would be simpler but isn't available until Git 2.22
|
||||
const output = await this.execGit(
|
||||
['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
|
||||
true
|
||||
)
|
||||
return !output.stdout.trim().startsWith('refs/heads/')
|
||||
}
|
||||
|
||||
async lfsFetch(ref: string): Promise<void> {
|
||||
const args = ['lfs', 'fetch', 'origin', ref]
|
||||
|
||||
const that = this
|
||||
await retryHelper.execute(async () => {
|
||||
await that.execGit(args)
|
||||
})
|
||||
}
|
||||
|
||||
async lfsInstall(): Promise<void> {
|
||||
await this.execGit(['lfs', 'install', '--local'])
|
||||
}
|
||||
|
||||
async log1(): Promise<string> {
|
||||
const output = await this.execGit(['log', '-1'])
|
||||
return output.stdout
|
||||
}
|
||||
|
||||
async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
|
||||
await this.execGit(['remote', 'add', remoteName, remoteUrl])
|
||||
}
|
||||
|
||||
removeEnvironmentVariable(name: string): void {
|
||||
delete this.gitEnv[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned.
|
||||
* For an annotated tag, the tag SHA is returned.
|
||||
* @param {string} ref For example: 'refs/heads/master' or '/refs/tags/v1'
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async revParse(ref: string): Promise<string> {
|
||||
const output = await this.execGit(['rev-parse', ref])
|
||||
return output.stdout.trim()
|
||||
}
|
||||
|
||||
setEnvironmentVariable(name: string, value: string): void {
|
||||
this.gitEnv[name] = value
|
||||
}
|
||||
|
||||
async shaExists(sha: string): Promise<boolean> {
|
||||
const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
|
||||
const output = await this.execGit(args, true)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
async submoduleForeach(command: string, recursive: boolean): Promise<string> {
|
||||
const args = ['submodule', 'foreach']
|
||||
if (recursive) {
|
||||
args.push('--recursive')
|
||||
}
|
||||
args.push(command)
|
||||
|
||||
const output = await this.execGit(args)
|
||||
return output.stdout
|
||||
}
|
||||
|
||||
async submoduleSync(recursive: boolean): Promise<void> {
|
||||
const args = ['submodule', 'sync']
|
||||
if (recursive) {
|
||||
args.push('--recursive')
|
||||
}
|
||||
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
|
||||
const args = ['-c', 'protocol.version=2']
|
||||
args.push('submodule', 'update', '--init', '--force')
|
||||
if (fetchDepth > 0) {
|
||||
args.push(`--depth=${fetchDepth}`)
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
args.push('--recursive')
|
||||
}
|
||||
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async tagExists(pattern: string): Promise<boolean> {
|
||||
const output = await this.execGit(['tag', '--list', pattern])
|
||||
return !!output.stdout.trim()
|
||||
}
|
||||
|
||||
async tryClean(): Promise<boolean> {
|
||||
const output = await this.execGit(['clean', '-ffdx'], true)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
async tryConfigUnset(
|
||||
configKey: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<boolean> {
|
||||
const output = await this.execGit(
|
||||
[
|
||||
'config',
|
||||
globalConfig ? '--global' : '--local',
|
||||
'--unset-all',
|
||||
configKey
|
||||
],
|
||||
true
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
|
||||
const output = await this.execGit(
|
||||
['config', '--local', 'gc.auto', '0'],
|
||||
true
|
||||
)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
async tryGetFetchUrl(): Promise<string> {
|
||||
const output = await this.execGit(
|
||||
['config', '--local', '--get', 'remote.origin.url'],
|
||||
true
|
||||
)
|
||||
|
||||
if (output.exitCode !== 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const stdout = output.stdout.trim()
|
||||
if (stdout.includes('\n')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return stdout
|
||||
}
|
||||
|
||||
async tryReset(): Promise<boolean> {
|
||||
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
|
||||
return output.exitCode === 0
|
||||
}
|
||||
|
||||
static async createCommandManager(
|
||||
workingDirectory: string,
|
||||
lfs: boolean
|
||||
): Promise<GitCommandManager> {
|
||||
const result = new GitCommandManager()
|
||||
await result.initializeCommandManager(workingDirectory, lfs)
|
||||
return result
|
||||
}
|
||||
|
||||
private async execGit(
|
||||
args: string[],
|
||||
allowAllExitCodes = false
|
||||
): Promise<GitOutput> {
|
||||
fshelper.directoryExistsSync(this.workingDirectory, true)
|
||||
|
||||
const result = new GitOutput()
|
||||
|
||||
const env = {}
|
||||
for (const key of Object.keys(process.env)) {
|
||||
env[key] = process.env[key]
|
||||
}
|
||||
for (const key of Object.keys(this.gitEnv)) {
|
||||
env[key] = this.gitEnv[key]
|
||||
}
|
||||
|
||||
const stdout: string[] = []
|
||||
|
||||
const options = {
|
||||
cwd: this.workingDirectory,
|
||||
env,
|
||||
ignoreReturnCode: allowAllExitCodes,
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => {
|
||||
stdout.push(data.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
|
||||
result.stdout = stdout.join('')
|
||||
return result
|
||||
}
|
||||
|
||||
private async initializeCommandManager(
|
||||
workingDirectory: string,
|
||||
lfs: boolean
|
||||
): Promise<void> {
|
||||
this.workingDirectory = workingDirectory
|
||||
|
||||
// Git-lfs will try to pull down assets if any of the local/user/system setting exist.
|
||||
// If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
|
||||
this.lfs = lfs
|
||||
if (!this.lfs) {
|
||||
this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
|
||||
}
|
||||
|
||||
this.gitPath = await io.which('git', true)
|
||||
|
||||
// Git version
|
||||
core.debug('Getting git version')
|
||||
let gitVersion = new GitVersion()
|
||||
let gitOutput = await this.execGit(['version'])
|
||||
let stdout = gitOutput.stdout.trim()
|
||||
if (!stdout.includes('\n')) {
|
||||
const match = stdout.match(/\d+\.\d+(\.\d+)?/)
|
||||
if (match) {
|
||||
gitVersion = new GitVersion(match[0])
|
||||
}
|
||||
}
|
||||
if (!gitVersion.isValid()) {
|
||||
throw new Error('Unable to determine git version')
|
||||
}
|
||||
|
||||
// Minimum git version
|
||||
if (!gitVersion.checkMinimum(MinimumGitVersion)) {
|
||||
throw new Error(
|
||||
`Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${gitVersion}`
|
||||
)
|
||||
}
|
||||
|
||||
if (this.lfs) {
|
||||
// Git-lfs version
|
||||
core.debug('Getting git-lfs version')
|
||||
let gitLfsVersion = new GitVersion()
|
||||
const gitLfsPath = await io.which('git-lfs', true)
|
||||
gitOutput = await this.execGit(['lfs', 'version'])
|
||||
stdout = gitOutput.stdout.trim()
|
||||
if (!stdout.includes('\n')) {
|
||||
const match = stdout.match(/\d+\.\d+(\.\d+)?/)
|
||||
if (match) {
|
||||
gitLfsVersion = new GitVersion(match[0])
|
||||
}
|
||||
}
|
||||
if (!gitLfsVersion.isValid()) {
|
||||
throw new Error('Unable to determine git-lfs version')
|
||||
}
|
||||
|
||||
// Minimum git-lfs version
|
||||
// Note:
|
||||
// - Auth header not supported before 2.1
|
||||
const minimumGitLfsVersion = new GitVersion('2.1')
|
||||
if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
|
||||
throw new Error(
|
||||
`Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the user agent
|
||||
const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`
|
||||
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
|
||||
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
|
||||
}
|
||||
}
|
||||
|
||||
class GitOutput {
|
||||
stdout = ''
|
||||
exitCode = 0
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import * as assert from 'assert'
|
||||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as fsHelper from './fs-helper'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import {IGitCommandManager} from './git-command-manager'
|
||||
|
||||
export async function prepareExistingDirectory(
|
||||
git: IGitCommandManager | undefined,
|
||||
repositoryPath: string,
|
||||
repositoryUrl: string,
|
||||
clean: boolean,
|
||||
ref: string
|
||||
): Promise<void> {
|
||||
assert.ok(repositoryPath, 'Expected repositoryPath to be defined')
|
||||
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined')
|
||||
|
||||
// Indicates whether to delete the directory contents
|
||||
let remove = false
|
||||
|
||||
// Check whether using git or REST API
|
||||
if (!git) {
|
||||
remove = true
|
||||
}
|
||||
// Fetch URL does not match
|
||||
else if (
|
||||
!fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')) ||
|
||||
repositoryUrl !== (await git.tryGetFetchUrl())
|
||||
) {
|
||||
remove = true
|
||||
} else {
|
||||
// Delete any index.lock and shallow.lock left by a previously canceled run or crashed git process
|
||||
const lockPaths = [
|
||||
path.join(repositoryPath, '.git', 'index.lock'),
|
||||
path.join(repositoryPath, '.git', 'shallow.lock')
|
||||
]
|
||||
for (const lockPath of lockPaths) {
|
||||
try {
|
||||
await io.rmRF(lockPath)
|
||||
} catch (error) {
|
||||
core.debug(`Unable to delete '${lockPath}'. ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
core.startGroup('Removing previously created refs, to avoid conflicts')
|
||||
// Checkout detached HEAD
|
||||
if (!(await git.isDetached())) {
|
||||
await git.checkoutDetach()
|
||||
}
|
||||
|
||||
// Remove all refs/heads/*
|
||||
let branches = await git.branchList(false)
|
||||
for (const branch of branches) {
|
||||
await git.branchDelete(false, branch)
|
||||
}
|
||||
|
||||
// Remove any conflicting refs/remotes/origin/*
|
||||
// Example 1: Consider ref is refs/heads/foo and previously fetched refs/remotes/origin/foo/bar
|
||||
// Example 2: Consider ref is refs/heads/foo/bar and previously fetched refs/remotes/origin/foo
|
||||
if (ref) {
|
||||
ref = ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
|
||||
if (ref.startsWith('refs/heads/')) {
|
||||
const upperName1 = ref.toUpperCase().substr('REFS/HEADS/'.length)
|
||||
const upperName1Slash = `${upperName1}/`
|
||||
branches = await git.branchList(true)
|
||||
for (const branch of branches) {
|
||||
const upperName2 = branch.substr('origin/'.length).toUpperCase()
|
||||
const upperName2Slash = `${upperName2}/`
|
||||
if (
|
||||
upperName1.startsWith(upperName2Slash) ||
|
||||
upperName2.startsWith(upperName1Slash)
|
||||
) {
|
||||
await git.branchDelete(true, branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
core.endGroup()
|
||||
|
||||
// Clean
|
||||
if (clean) {
|
||||
core.startGroup('Cleaning the repository')
|
||||
if (!(await git.tryClean())) {
|
||||
core.debug(
|
||||
`The clean command failed. This might be caused by: 1) path too long, 2) permission issue, or 3) file in use. For futher investigation, manually run 'git clean -ffdx' on the directory '${repositoryPath}'.`
|
||||
)
|
||||
remove = true
|
||||
} else if (!(await git.tryReset())) {
|
||||
remove = true
|
||||
}
|
||||
core.endGroup()
|
||||
|
||||
if (remove) {
|
||||
core.warning(
|
||||
`Unable to clean or reset the repository. The repository will be recreated instead.`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Unable to prepare the existing repository. The repository will be recreated instead.`
|
||||
)
|
||||
remove = true
|
||||
}
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
// Delete the contents of the directory. Don't delete the directory itself
|
||||
// since it might be the current working directory.
|
||||
core.info(`Deleting the contents of '${repositoryPath}'`)
|
||||
for (const file of await fs.promises.readdir(repositoryPath)) {
|
||||
await io.rmRF(path.join(repositoryPath, file))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as fsHelper from './fs-helper'
|
||||
import * as gitAuthHelper from './git-auth-helper'
|
||||
import * as gitCommandManager from './git-command-manager'
|
||||
import * as gitDirectoryHelper from './git-directory-helper'
|
||||
import * as githubApiHelper from './github-api-helper'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import * as refHelper from './ref-helper'
|
||||
import * as stateHelper from './state-helper'
|
||||
import * as urlHelper from './url-helper'
|
||||
import {IGitCommandManager} from './git-command-manager'
|
||||
import {IGitSourceSettings} from './git-source-settings'
|
||||
|
||||
export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
||||
// Repository URL
|
||||
core.info(
|
||||
`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`
|
||||
)
|
||||
const repositoryUrl = urlHelper.getFetchUrl(settings)
|
||||
|
||||
// Remove conflicting file path
|
||||
if (fsHelper.fileExistsSync(settings.repositoryPath)) {
|
||||
await io.rmRF(settings.repositoryPath)
|
||||
}
|
||||
|
||||
// Create directory
|
||||
let isExisting = true
|
||||
if (!fsHelper.directoryExistsSync(settings.repositoryPath)) {
|
||||
isExisting = false
|
||||
await io.mkdirP(settings.repositoryPath)
|
||||
}
|
||||
|
||||
// Git command manager
|
||||
core.startGroup('Getting Git version info')
|
||||
const git = await getGitCommandManager(settings)
|
||||
core.endGroup()
|
||||
|
||||
// Prepare existing directory, otherwise recreate
|
||||
if (isExisting) {
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
settings.repositoryPath,
|
||||
repositoryUrl,
|
||||
settings.clean,
|
||||
settings.ref
|
||||
)
|
||||
}
|
||||
|
||||
if (!git) {
|
||||
// Downloading using REST API
|
||||
core.info(`The repository will be downloaded using the GitHub REST API`)
|
||||
core.info(
|
||||
`To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH`
|
||||
)
|
||||
if (settings.submodules) {
|
||||
throw new Error(
|
||||
`Input 'submodules' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH.`
|
||||
)
|
||||
} else if (settings.sshKey) {
|
||||
throw new Error(
|
||||
`Input 'ssh-key' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH.`
|
||||
)
|
||||
}
|
||||
|
||||
await githubApiHelper.downloadRepository(
|
||||
settings.authToken,
|
||||
settings.repositoryOwner,
|
||||
settings.repositoryName,
|
||||
settings.ref,
|
||||
settings.commit,
|
||||
settings.repositoryPath
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Save state for POST action
|
||||
stateHelper.setRepositoryPath(settings.repositoryPath)
|
||||
|
||||
// Initialize the repository
|
||||
if (
|
||||
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
|
||||
) {
|
||||
core.startGroup('Initializing the repository')
|
||||
await git.init()
|
||||
await git.remoteAdd('origin', repositoryUrl)
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
// Disable automatic garbage collection
|
||||
core.startGroup('Disabling automatic garbage collection')
|
||||
if (!(await git.tryDisableAutomaticGarbageCollection())) {
|
||||
core.warning(
|
||||
`Unable to turn off git automatic garbage collection. The git fetch operation may trigger garbage collection and cause a delay.`
|
||||
)
|
||||
}
|
||||
core.endGroup()
|
||||
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
||||
try {
|
||||
// Configure auth
|
||||
core.startGroup('Setting up auth')
|
||||
await authHelper.configureAuth()
|
||||
core.endGroup()
|
||||
|
||||
// Determine the default branch
|
||||
if (!settings.ref && !settings.commit) {
|
||||
core.startGroup('Determining the default branch')
|
||||
if (settings.sshKey) {
|
||||
settings.ref = await git.getDefaultBranch(repositoryUrl)
|
||||
} else {
|
||||
settings.ref = await githubApiHelper.getDefaultBranch(
|
||||
settings.authToken,
|
||||
settings.repositoryOwner,
|
||||
settings.repositoryName
|
||||
)
|
||||
}
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
// LFS install
|
||||
if (settings.lfs) {
|
||||
await git.lfsInstall()
|
||||
}
|
||||
|
||||
// Fetch
|
||||
core.startGroup('Fetching the repository')
|
||||
if (settings.fetchDepth <= 0) {
|
||||
// Fetch all branches and tags
|
||||
let refSpec = refHelper.getRefSpecForAllHistory(
|
||||
settings.ref,
|
||||
settings.commit
|
||||
)
|
||||
await git.fetch(refSpec)
|
||||
|
||||
// When all history is fetched, the ref we're interested in may have moved to a different
|
||||
// commit (push or force push). If so, fetch again with a targeted refspec.
|
||||
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
||||
await git.fetch(refSpec)
|
||||
}
|
||||
} else {
|
||||
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
||||
await git.fetch(refSpec, settings.fetchDepth)
|
||||
}
|
||||
core.endGroup()
|
||||
|
||||
// Checkout info
|
||||
core.startGroup('Determining the checkout info')
|
||||
const checkoutInfo = await refHelper.getCheckoutInfo(
|
||||
git,
|
||||
settings.ref,
|
||||
settings.commit
|
||||
)
|
||||
core.endGroup()
|
||||
|
||||
// LFS fetch
|
||||
// Explicit lfs-fetch to avoid slow checkout (fetches one lfs object at a time).
|
||||
// Explicit lfs fetch will fetch lfs objects in parallel.
|
||||
if (settings.lfs) {
|
||||
core.startGroup('Fetching LFS objects')
|
||||
await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref)
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
// Checkout
|
||||
core.startGroup('Checking out the ref')
|
||||
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
|
||||
core.endGroup()
|
||||
|
||||
// Submodules
|
||||
if (settings.submodules) {
|
||||
try {
|
||||
// Temporarily override global config
|
||||
core.startGroup('Setting up auth for fetching submodules')
|
||||
await authHelper.configureGlobalAuth()
|
||||
core.endGroup()
|
||||
|
||||
// Checkout submodules
|
||||
core.startGroup('Fetching submodules')
|
||||
await git.submoduleSync(settings.nestedSubmodules)
|
||||
await git.submoduleUpdate(
|
||||
settings.fetchDepth,
|
||||
settings.nestedSubmodules
|
||||
)
|
||||
await git.submoduleForeach(
|
||||
'git config --local gc.auto 0',
|
||||
settings.nestedSubmodules
|
||||
)
|
||||
core.endGroup()
|
||||
|
||||
// Persist credentials
|
||||
if (settings.persistCredentials) {
|
||||
core.startGroup('Persisting credentials for submodules')
|
||||
await authHelper.configureSubmoduleAuth()
|
||||
core.endGroup()
|
||||
}
|
||||
} finally {
|
||||
// Remove temporary global config override
|
||||
await authHelper.removeGlobalAuth()
|
||||
}
|
||||
}
|
||||
|
||||
// Dump some info about the checked out commit
|
||||
const commitInfo = await git.log1()
|
||||
|
||||
// Check for incorrect pull request merge commit
|
||||
await refHelper.checkCommitInfo(
|
||||
settings.authToken,
|
||||
commitInfo,
|
||||
settings.repositoryOwner,
|
||||
settings.repositoryName,
|
||||
settings.ref,
|
||||
settings.commit
|
||||
)
|
||||
} finally {
|
||||
// Remove auth
|
||||
if (!settings.persistCredentials) {
|
||||
core.startGroup('Removing auth')
|
||||
await authHelper.removeAuth()
|
||||
core.endGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanup(repositoryPath: string): Promise<void> {
|
||||
// Repo exists?
|
||||
if (
|
||||
!repositoryPath ||
|
||||
!fsHelper.fileExistsSync(path.join(repositoryPath, '.git', 'config'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let git: IGitCommandManager
|
||||
try {
|
||||
git = await gitCommandManager.createCommandManager(repositoryPath, false)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove auth
|
||||
const authHelper = gitAuthHelper.createAuthHelper(git)
|
||||
await authHelper.removeAuth()
|
||||
}
|
||||
|
||||
async function getGitCommandManager(
|
||||
settings: IGitSourceSettings
|
||||
): Promise<IGitCommandManager | undefined> {
|
||||
core.info(`Working directory is '${settings.repositoryPath}'`)
|
||||
try {
|
||||
return await gitCommandManager.createCommandManager(
|
||||
settings.repositoryPath,
|
||||
settings.lfs
|
||||
)
|
||||
} catch (err) {
|
||||
// Git is required for LFS
|
||||
if (settings.lfs) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// Otherwise fallback to REST API
|
||||
return undefined
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
export interface IGitSourceSettings {
|
||||
/**
|
||||
* The location on disk where the repository will be placed
|
||||
*/
|
||||
repositoryPath: string
|
||||
|
||||
/**
|
||||
* The repository owner
|
||||
*/
|
||||
repositoryOwner: string
|
||||
|
||||
/**
|
||||
* The repository name
|
||||
*/
|
||||
repositoryName: string
|
||||
|
||||
/**
|
||||
* The ref to fetch
|
||||
*/
|
||||
ref: string
|
||||
|
||||
/**
|
||||
* The commit to checkout
|
||||
*/
|
||||
commit: string
|
||||
|
||||
/**
|
||||
* Indicates whether to clean the repository
|
||||
*/
|
||||
clean: boolean
|
||||
|
||||
/**
|
||||
* The depth when fetching
|
||||
*/
|
||||
fetchDepth: number
|
||||
|
||||
/**
|
||||
* Indicates whether to fetch LFS objects
|
||||
*/
|
||||
lfs: boolean
|
||||
|
||||
/**
|
||||
* Indicates whether to checkout submodules
|
||||
*/
|
||||
submodules: boolean
|
||||
|
||||
/**
|
||||
* Indicates whether to recursively checkout submodules
|
||||
*/
|
||||
nestedSubmodules: boolean
|
||||
|
||||
/**
|
||||
* The auth token to use when fetching the repository
|
||||
*/
|
||||
authToken: string
|
||||
|
||||
/**
|
||||
* The SSH key to configure
|
||||
*/
|
||||
sshKey: string
|
||||
|
||||
/**
|
||||
* Additional SSH known hosts
|
||||
*/
|
||||
sshKnownHosts: string
|
||||
|
||||
/**
|
||||
* Indicates whether the server must be a known host
|
||||
*/
|
||||
sshStrict: boolean
|
||||
|
||||
/**
|
||||
* Indicates whether to persist the credentials on disk to enable scripting authenticated git commands
|
||||
*/
|
||||
persistCredentials: boolean
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
export class GitVersion {
|
||||
private readonly major: number = NaN
|
||||
private readonly minor: number = NaN
|
||||
private readonly patch: number = NaN
|
||||
|
||||
/**
|
||||
* Used for comparing the version of git and git-lfs against the minimum required version
|
||||
* @param version the version string, e.g. 1.2 or 1.2.3
|
||||
*/
|
||||
constructor(version?: string) {
|
||||
if (version) {
|
||||
const match = version.match(/^(\d+)\.(\d+)(\.(\d+))?$/)
|
||||
if (match) {
|
||||
this.major = Number(match[1])
|
||||
this.minor = Number(match[2])
|
||||
if (match[4]) {
|
||||
this.patch = Number(match[4])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the instance against a minimum required version
|
||||
* @param minimum Minimum version
|
||||
*/
|
||||
checkMinimum(minimum: GitVersion): boolean {
|
||||
if (!minimum.isValid()) {
|
||||
throw new Error('Arg minimum is not a valid version')
|
||||
}
|
||||
|
||||
// Major is insufficient
|
||||
if (this.major < minimum.major) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Major is equal
|
||||
if (this.major === minimum.major) {
|
||||
// Minor is insufficient
|
||||
if (this.minor < minimum.minor) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Minor is equal
|
||||
if (this.minor === minimum.minor) {
|
||||
// Patch is insufficient
|
||||
if (this.patch && this.patch < (minimum.patch || 0)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the instance was constructed from a valid version string
|
||||
*/
|
||||
isValid(): boolean {
|
||||
return !isNaN(this.major)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the version as a string, e.g. 1.2 or 1.2.3
|
||||
*/
|
||||
toString(): string {
|
||||
let result = ''
|
||||
if (this.isValid()) {
|
||||
result = `${this.major}.${this.minor}`
|
||||
if (!isNaN(this.patch)) {
|
||||
result += `.${this.patch}`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import * as assert from 'assert'
|
||||
import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as github from '@actions/github'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import * as retryHelper from './retry-helper'
|
||||
import * as toolCache from '@actions/tool-cache'
|
||||
import {default as uuid} from 'uuid/v4'
|
||||
import {Octokit} from '@octokit/rest'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
export async function downloadRepository(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
commit: string,
|
||||
repositoryPath: string
|
||||
): Promise<void> {
|
||||
// Determine the default branch
|
||||
if (!ref && !commit) {
|
||||
core.info('Determining the default branch')
|
||||
ref = await getDefaultBranch(authToken, owner, repo)
|
||||
}
|
||||
|
||||
// Download the archive
|
||||
let archiveData = await retryHelper.execute(async () => {
|
||||
core.info('Downloading the archive')
|
||||
return await downloadArchive(authToken, owner, repo, ref, commit)
|
||||
})
|
||||
|
||||
// Write archive to disk
|
||||
core.info('Writing archive to disk')
|
||||
const uniqueId = uuid()
|
||||
const archivePath = path.join(repositoryPath, `${uniqueId}.tar.gz`)
|
||||
await fs.promises.writeFile(archivePath, archiveData)
|
||||
archiveData = Buffer.from('') // Free memory
|
||||
|
||||
// Extract archive
|
||||
core.info('Extracting the archive')
|
||||
const extractPath = path.join(repositoryPath, uniqueId)
|
||||
await io.mkdirP(extractPath)
|
||||
if (IS_WINDOWS) {
|
||||
await toolCache.extractZip(archivePath, extractPath)
|
||||
} else {
|
||||
await toolCache.extractTar(archivePath, extractPath)
|
||||
}
|
||||
io.rmRF(archivePath)
|
||||
|
||||
// Determine the path of the repository content. The archive contains
|
||||
// a top-level folder and the repository content is inside.
|
||||
const archiveFileNames = await fs.promises.readdir(extractPath)
|
||||
assert.ok(
|
||||
archiveFileNames.length == 1,
|
||||
'Expected exactly one directory inside archive'
|
||||
)
|
||||
const archiveVersion = archiveFileNames[0] // The top-level folder name includes the short SHA
|
||||
core.info(`Resolved version ${archiveVersion}`)
|
||||
const tempRepositoryPath = path.join(extractPath, archiveVersion)
|
||||
|
||||
// Move the files
|
||||
for (const fileName of await fs.promises.readdir(tempRepositoryPath)) {
|
||||
const sourcePath = path.join(tempRepositoryPath, fileName)
|
||||
const targetPath = path.join(repositoryPath, fileName)
|
||||
if (IS_WINDOWS) {
|
||||
await io.cp(sourcePath, targetPath, {recursive: true}) // Copy on Windows (Windows Defender may have a lock)
|
||||
} else {
|
||||
await io.mv(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
io.rmRF(extractPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the default branch name
|
||||
*/
|
||||
export async function getDefaultBranch(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<string> {
|
||||
return await retryHelper.execute(async () => {
|
||||
core.info('Retrieving the default branch name')
|
||||
const octokit = new github.GitHub(authToken)
|
||||
let result: string
|
||||
try {
|
||||
// Get the default branch from the repo info
|
||||
const response = await octokit.repos.get({owner, repo})
|
||||
result = response.data.default_branch
|
||||
assert.ok(result, 'default_branch cannot be empty')
|
||||
} catch (err) {
|
||||
// Handle .wiki repo
|
||||
if (err['status'] === 404 && repo.toUpperCase().endsWith('.WIKI')) {
|
||||
result = 'master'
|
||||
}
|
||||
// Otherwise error
|
||||
else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Print the default branch
|
||||
core.info(`Default branch '${result}'`)
|
||||
|
||||
// Prefix with 'refs/heads'
|
||||
if (!result.startsWith('refs/')) {
|
||||
result = `refs/heads/${result}`
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadArchive(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
commit: string
|
||||
): Promise<Buffer> {
|
||||
const octokit = new github.GitHub(authToken)
|
||||
const params: Octokit.ReposGetArchiveLinkParams = {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
archive_format: IS_WINDOWS ? 'zipball' : 'tarball',
|
||||
ref: commit || ref
|
||||
}
|
||||
const response = await octokit.repos.getArchiveLink(params)
|
||||
if (response.status != 200) {
|
||||
throw new Error(
|
||||
`Unexpected response from GitHub API. Status: ${response.status}, Data: ${response.data}`
|
||||
)
|
||||
}
|
||||
|
||||
return Buffer.from(response.data) // response.data is ArrayBuffer
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as fsHelper from './fs-helper'
|
||||
import * as github from '@actions/github'
|
||||
import * as path from 'path'
|
||||
import {IGitSourceSettings} from './git-source-settings'
|
||||
|
||||
export function getInputs(): IGitSourceSettings {
|
||||
const result = ({} as unknown) as IGitSourceSettings
|
||||
|
||||
// GitHub workspace
|
||||
let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
|
||||
if (!githubWorkspacePath) {
|
||||
throw new Error('GITHUB_WORKSPACE not defined')
|
||||
}
|
||||
githubWorkspacePath = path.resolve(githubWorkspacePath)
|
||||
core.debug(`GITHUB_WORKSPACE = '${githubWorkspacePath}'`)
|
||||
fsHelper.directoryExistsSync(githubWorkspacePath, true)
|
||||
|
||||
// Qualified repository
|
||||
const qualifiedRepository =
|
||||
core.getInput('repository') ||
|
||||
`${github.context.repo.owner}/${github.context.repo.repo}`
|
||||
core.debug(`qualified repository = '${qualifiedRepository}'`)
|
||||
const splitRepository = qualifiedRepository.split('/')
|
||||
if (
|
||||
splitRepository.length !== 2 ||
|
||||
!splitRepository[0] ||
|
||||
!splitRepository[1]
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid repository '${qualifiedRepository}'. Expected format {owner}/{repo}.`
|
||||
)
|
||||
}
|
||||
result.repositoryOwner = splitRepository[0]
|
||||
result.repositoryName = splitRepository[1]
|
||||
|
||||
// Repository path
|
||||
result.repositoryPath = core.getInput('path') || '.'
|
||||
result.repositoryPath = path.resolve(
|
||||
githubWorkspacePath,
|
||||
result.repositoryPath
|
||||
)
|
||||
if (
|
||||
!(result.repositoryPath + path.sep).startsWith(
|
||||
githubWorkspacePath + path.sep
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Repository path '${result.repositoryPath}' is not under '${githubWorkspacePath}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Workflow repository?
|
||||
const isWorkflowRepository =
|
||||
qualifiedRepository.toUpperCase() ===
|
||||
`${github.context.repo.owner}/${github.context.repo.repo}`.toUpperCase()
|
||||
|
||||
// Source branch, source version
|
||||
result.ref = core.getInput('ref')
|
||||
if (!result.ref) {
|
||||
if (isWorkflowRepository) {
|
||||
result.ref = github.context.ref
|
||||
result.commit = github.context.sha
|
||||
|
||||
// Some events have an unqualifed ref. For example when a PR is merged (pull_request closed event),
|
||||
// the ref is unqualifed like "master" instead of "refs/heads/master".
|
||||
if (result.commit && result.ref && !result.ref.startsWith('refs/')) {
|
||||
result.ref = `refs/heads/${result.ref}`
|
||||
}
|
||||
}
|
||||
}
|
||||
// SHA?
|
||||
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
|
||||
result.commit = result.ref
|
||||
result.ref = ''
|
||||
}
|
||||
core.debug(`ref = '${result.ref}'`)
|
||||
core.debug(`commit = '${result.commit}'`)
|
||||
|
||||
// Clean
|
||||
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
|
||||
core.debug(`clean = ${result.clean}`)
|
||||
|
||||
// Fetch depth
|
||||
result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'))
|
||||
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
|
||||
result.fetchDepth = 0
|
||||
}
|
||||
core.debug(`fetch depth = ${result.fetchDepth}`)
|
||||
|
||||
// LFS
|
||||
result.lfs = (core.getInput('lfs') || 'false').toUpperCase() === 'TRUE'
|
||||
core.debug(`lfs = ${result.lfs}`)
|
||||
|
||||
// Submodules
|
||||
result.submodules = false
|
||||
result.nestedSubmodules = false
|
||||
const submodulesString = (core.getInput('submodules') || '').toUpperCase()
|
||||
if (submodulesString == 'RECURSIVE') {
|
||||
result.submodules = true
|
||||
result.nestedSubmodules = true
|
||||
} else if (submodulesString == 'TRUE') {
|
||||
result.submodules = true
|
||||
}
|
||||
core.debug(`submodules = ${result.submodules}`)
|
||||
core.debug(`recursive submodules = ${result.nestedSubmodules}`)
|
||||
|
||||
// Auth token
|
||||
result.authToken = core.getInput('token', {required: true})
|
||||
|
||||
// SSH
|
||||
result.sshKey = core.getInput('ssh-key')
|
||||
result.sshKnownHosts = core.getInput('ssh-known-hosts')
|
||||
result.sshStrict =
|
||||
(core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE'
|
||||
|
||||
// Persist credentials
|
||||
result.persistCredentials =
|
||||
(core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'
|
||||
|
||||
return result
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as coreCommand from '@actions/core/lib/command'
|
||||
import * as gitSourceProvider from './git-source-provider'
|
||||
import * as inputHelper from './input-helper'
|
||||
import * as path from 'path'
|
||||
import * as stateHelper from './state-helper'
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const sourceSettings = inputHelper.getInputs()
|
||||
|
||||
try {
|
||||
// Register problem matcher
|
||||
coreCommand.issueCommand(
|
||||
'add-matcher',
|
||||
{},
|
||||
path.join(__dirname, 'problem-matcher.json')
|
||||
)
|
||||
|
||||
// Get sources
|
||||
await gitSourceProvider.getSource(sourceSettings)
|
||||
} finally {
|
||||
// Unregister problem matcher
|
||||
coreCommand.issueCommand('remove-matcher', {owner: 'checkout-git'}, '')
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
await gitSourceProvider.cleanup(stateHelper.RepositoryPath)
|
||||
} catch (error) {
|
||||
core.warning(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Main
|
||||
if (!stateHelper.IsPost) {
|
||||
run()
|
||||
}
|
||||
// Post
|
||||
else {
|
||||
cleanup()
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
//
|
||||
// SUMMARY
|
||||
//
|
||||
// This script rebuilds the usage section in the README.md to be consistent with the action.yml
|
||||
|
||||
function updateUsage(
|
||||
actionReference: string,
|
||||
actionYamlPath: string = 'action.yml',
|
||||
readmePath: string = 'README.md',
|
||||
startToken: string = '<!-- start usage -->',
|
||||
endToken: string = '<!-- end usage -->'
|
||||
): void {
|
||||
if (!actionReference) {
|
||||
throw new Error('Parameter actionReference must not be empty')
|
||||
}
|
||||
|
||||
// Load the action.yml
|
||||
const actionYaml = yaml.safeLoad(fs.readFileSync(actionYamlPath).toString())
|
||||
|
||||
// Load the README
|
||||
const originalReadme = fs.readFileSync(readmePath).toString()
|
||||
|
||||
// Find the start token
|
||||
const startTokenIndex = originalReadme.indexOf(startToken)
|
||||
if (startTokenIndex < 0) {
|
||||
throw new Error(`Start token '${startToken}' not found`)
|
||||
}
|
||||
|
||||
// Find the end token
|
||||
const endTokenIndex = originalReadme.indexOf(endToken)
|
||||
if (endTokenIndex < 0) {
|
||||
throw new Error(`End token '${endToken}' not found`)
|
||||
} else if (endTokenIndex < startTokenIndex) {
|
||||
throw new Error('Start token must appear before end token')
|
||||
}
|
||||
|
||||
// Build the new README
|
||||
const newReadme: string[] = []
|
||||
|
||||
// Append the beginning
|
||||
newReadme.push(originalReadme.substr(0, startTokenIndex + startToken.length))
|
||||
|
||||
// Build the new usage section
|
||||
newReadme.push('```yaml', `- uses: ${actionReference}`, ' with:')
|
||||
const inputs = actionYaml.inputs
|
||||
let firstInput = true
|
||||
for (const key of Object.keys(inputs)) {
|
||||
const input = inputs[key]
|
||||
|
||||
// Line break between inputs
|
||||
if (!firstInput) {
|
||||
newReadme.push('')
|
||||
}
|
||||
|
||||
// Constrain the width of the description
|
||||
const width = 80
|
||||
let description = (input.description as string)
|
||||
.trimRight()
|
||||
.replace(/\r\n/g, '\n') // Convert CR to LF
|
||||
.replace(/ +/g, ' ') // Squash consecutive spaces
|
||||
.replace(/ \n/g, '\n') // Squash space followed by newline
|
||||
while (description) {
|
||||
// Longer than width? Find a space to break apart
|
||||
let segment: string = description
|
||||
if (description.length > width) {
|
||||
segment = description.substr(0, width + 1)
|
||||
while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) {
|
||||
segment = segment.substr(0, segment.length - 1)
|
||||
}
|
||||
|
||||
// Trimmed too much?
|
||||
if (segment.length < width * 0.67) {
|
||||
segment = description
|
||||
}
|
||||
} else {
|
||||
segment = description
|
||||
}
|
||||
|
||||
// Check for newline
|
||||
const newlineIndex = segment.indexOf('\n')
|
||||
if (newlineIndex >= 0) {
|
||||
segment = segment.substr(0, newlineIndex + 1)
|
||||
}
|
||||
|
||||
// Append segment
|
||||
newReadme.push(` # ${segment}`.trimRight())
|
||||
|
||||
// Remaining
|
||||
description = description.substr(segment.length)
|
||||
}
|
||||
|
||||
if (input.default !== undefined) {
|
||||
// Append blank line if description had paragraphs
|
||||
if ((input.description as string).trimRight().match(/\n[ ]*\r?\n/)) {
|
||||
newReadme.push(` #`)
|
||||
}
|
||||
|
||||
// Default
|
||||
newReadme.push(` # Default: ${input.default}`)
|
||||
}
|
||||
|
||||
// Input name
|
||||
newReadme.push(` ${key}: ''`)
|
||||
|
||||
firstInput = false
|
||||
}
|
||||
|
||||
newReadme.push('```')
|
||||
|
||||
// Append the end
|
||||
newReadme.push(originalReadme.substr(endTokenIndex))
|
||||
|
||||
// Write the new README
|
||||
fs.writeFileSync(readmePath, newReadme.join(os.EOL))
|
||||
}
|
||||
|
||||
updateUsage(
|
||||
'actions/checkout@v2',
|
||||
path.join(__dirname, '..', '..', 'action.yml'),
|
||||
path.join(__dirname, '..', '..', 'README.md')
|
||||
)
|
|
@ -0,0 +1,283 @@
|
|||
import {URL} from 'url'
|
||||
import {IGitCommandManager} from './git-command-manager'
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
|
||||
export const tagsRefSpec = '+refs/tags/*:refs/tags/*'
|
||||
|
||||
export interface ICheckoutInfo {
|
||||
ref: string
|
||||
startPoint: string
|
||||
}
|
||||
|
||||
export async function getCheckoutInfo(
|
||||
git: IGitCommandManager,
|
||||
ref: string,
|
||||
commit: string
|
||||
): Promise<ICheckoutInfo> {
|
||||
if (!git) {
|
||||
throw new Error('Arg git cannot be empty')
|
||||
}
|
||||
|
||||
if (!ref && !commit) {
|
||||
throw new Error('Args ref and commit cannot both be empty')
|
||||
}
|
||||
|
||||
const result = ({} as unknown) as ICheckoutInfo
|
||||
const upperRef = (ref || '').toUpperCase()
|
||||
|
||||
// SHA only
|
||||
if (!ref) {
|
||||
result.ref = commit
|
||||
}
|
||||
// refs/heads/
|
||||
else if (upperRef.startsWith('REFS/HEADS/')) {
|
||||
const branch = ref.substring('refs/heads/'.length)
|
||||
result.ref = branch
|
||||
result.startPoint = `refs/remotes/origin/${branch}`
|
||||
}
|
||||
// refs/pull/
|
||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||
const branch = ref.substring('refs/pull/'.length)
|
||||
result.ref = `refs/remotes/pull/${branch}`
|
||||
}
|
||||
// refs/tags/
|
||||
else if (upperRef.startsWith('REFS/')) {
|
||||
result.ref = ref
|
||||
}
|
||||
// Unqualified ref, check for a matching branch or tag
|
||||
else {
|
||||
if (await git.branchExists(true, `origin/${ref}`)) {
|
||||
result.ref = ref
|
||||
result.startPoint = `refs/remotes/origin/${ref}`
|
||||
} else if (await git.tagExists(`${ref}`)) {
|
||||
result.ref = `refs/tags/${ref}`
|
||||
} else {
|
||||
throw new Error(
|
||||
`A branch or tag with the name '${ref}' could not be found`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getRefSpecForAllHistory(ref: string, commit: string): string[] {
|
||||
const result = ['+refs/heads/*:refs/remotes/origin/*', tagsRefSpec]
|
||||
if (ref && ref.toUpperCase().startsWith('REFS/PULL/')) {
|
||||
const branch = ref.substring('refs/pull/'.length)
|
||||
result.push(`+${commit || ref}:refs/remotes/pull/${branch}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getRefSpec(ref: string, commit: string): string[] {
|
||||
if (!ref && !commit) {
|
||||
throw new Error('Args ref and commit cannot both be empty')
|
||||
}
|
||||
|
||||
const upperRef = (ref || '').toUpperCase()
|
||||
|
||||
// SHA
|
||||
if (commit) {
|
||||
// refs/heads
|
||||
if (upperRef.startsWith('REFS/HEADS/')) {
|
||||
const branch = ref.substring('refs/heads/'.length)
|
||||
return [`+${commit}:refs/remotes/origin/${branch}`]
|
||||
}
|
||||
// refs/pull/
|
||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||
const branch = ref.substring('refs/pull/'.length)
|
||||
return [`+${commit}:refs/remotes/pull/${branch}`]
|
||||
}
|
||||
// refs/tags/
|
||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||
return [`+${commit}:${ref}`]
|
||||
}
|
||||
// Otherwise no destination ref
|
||||
else {
|
||||
return [commit]
|
||||
}
|
||||
}
|
||||
// Unqualified ref, check for a matching branch or tag
|
||||
else if (!upperRef.startsWith('REFS/')) {
|
||||
return [
|
||||
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
|
||||
`+refs/tags/${ref}*:refs/tags/${ref}*`
|
||||
]
|
||||
}
|
||||
// refs/heads/
|
||||
else if (upperRef.startsWith('REFS/HEADS/')) {
|
||||
const branch = ref.substring('refs/heads/'.length)
|
||||
return [`+${ref}:refs/remotes/origin/${branch}`]
|
||||
}
|
||||
// refs/pull/
|
||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||
const branch = ref.substring('refs/pull/'.length)
|
||||
return [`+${ref}:refs/remotes/pull/${branch}`]
|
||||
}
|
||||
// refs/tags/
|
||||
else {
|
||||
return [`+${ref}:${ref}`]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether the initial fetch created the ref at the expected commit
|
||||
*/
|
||||
export async function testRef(
|
||||
git: IGitCommandManager,
|
||||
ref: string,
|
||||
commit: string
|
||||
): Promise<boolean> {
|
||||
if (!git) {
|
||||
throw new Error('Arg git cannot be empty')
|
||||
}
|
||||
|
||||
if (!ref && !commit) {
|
||||
throw new Error('Args ref and commit cannot both be empty')
|
||||
}
|
||||
|
||||
// No SHA? Nothing to test
|
||||
if (!commit) {
|
||||
return true
|
||||
}
|
||||
// SHA only?
|
||||
else if (!ref) {
|
||||
return await git.shaExists(commit)
|
||||
}
|
||||
|
||||
const upperRef = ref.toUpperCase()
|
||||
|
||||
// refs/heads/
|
||||
if (upperRef.startsWith('REFS/HEADS/')) {
|
||||
const branch = ref.substring('refs/heads/'.length)
|
||||
return (
|
||||
(await git.branchExists(true, `origin/${branch}`)) &&
|
||||
commit === (await git.revParse(`refs/remotes/origin/${branch}`))
|
||||
)
|
||||
}
|
||||
// refs/pull/
|
||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||
// Assume matches because fetched using the commit
|
||||
return true
|
||||
}
|
||||
// refs/tags/
|
||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||
const tagName = ref.substring('refs/tags/'.length)
|
||||
return (
|
||||
(await git.tagExists(tagName)) && commit === (await git.revParse(ref))
|
||||
)
|
||||
}
|
||||
// Unexpected
|
||||
else {
|
||||
core.debug(`Unexpected ref format '${ref}' when testing ref info`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCommitInfo(
|
||||
token: string,
|
||||
commitInfo: string,
|
||||
repositoryOwner: string,
|
||||
repositoryName: string,
|
||||
ref: string,
|
||||
commit: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// GHES?
|
||||
if (isGhes()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auth token?
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Public PR synchronize, for workflow repo?
|
||||
if (
|
||||
fromPayload('repository.private') !== false ||
|
||||
github.context.eventName !== 'pull_request' ||
|
||||
fromPayload('action') !== 'synchronize' ||
|
||||
repositoryOwner !== github.context.repo.owner ||
|
||||
repositoryName !== github.context.repo.repo ||
|
||||
ref !== github.context.ref ||
|
||||
!ref.startsWith('refs/pull/') ||
|
||||
commit !== github.context.sha
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Head SHA
|
||||
const expectedHeadSha = fromPayload('after')
|
||||
if (!expectedHeadSha) {
|
||||
core.debug('Unable to determine head sha')
|
||||
return
|
||||
}
|
||||
|
||||
// Base SHA
|
||||
const expectedBaseSha = fromPayload('pull_request.base.sha')
|
||||
if (!expectedBaseSha) {
|
||||
core.debug('Unable to determine base sha')
|
||||
return
|
||||
}
|
||||
|
||||
// Expected message?
|
||||
const expectedMessage = `Merge ${expectedHeadSha} into ${expectedBaseSha}`
|
||||
if (commitInfo.indexOf(expectedMessage) >= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract details from message
|
||||
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/)
|
||||
if (!match) {
|
||||
core.debug('Unexpected message format')
|
||||
return
|
||||
}
|
||||
|
||||
// Post telemetry
|
||||
const actualHeadSha = match[1]
|
||||
if (actualHeadSha !== expectedHeadSha) {
|
||||
core.debug(
|
||||
`Expected head sha ${expectedHeadSha}; actual head sha ${actualHeadSha}`
|
||||
)
|
||||
const octokit = new github.GitHub(token, {
|
||||
userAgent: `actions-checkout-tracepoint/1.0 (code=STALE_MERGE;owner=${repositoryOwner};repo=${repositoryName};pr=${fromPayload(
|
||||
'number'
|
||||
)};run_id=${
|
||||
process.env['GITHUB_RUN_ID']
|
||||
};expected_head_sha=${expectedHeadSha};actual_head_sha=${actualHeadSha})`
|
||||
})
|
||||
await octokit.repos.get({owner: repositoryOwner, repo: repositoryName})
|
||||
}
|
||||
} catch (err) {
|
||||
core.debug(`Error when validating commit info: ${err.stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fromPayload(path: string): any {
|
||||
return select(github.context.payload, path)
|
||||
}
|
||||
|
||||
function select(obj: any, path: string): any {
|
||||
if (!obj) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const i = path.indexOf('.')
|
||||
if (i < 0) {
|
||||
return obj[path]
|
||||
}
|
||||
|
||||
const key = path.substr(0, i)
|
||||
return select(obj[key], path.substr(i + 1))
|
||||
}
|
||||
|
||||
function isGhes(): boolean {
|
||||
const ghUrl = new URL(
|
||||
process.env['GITHUB_SERVER_URL'] || 'https://github.com'
|
||||
)
|
||||
return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export function escape(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_]/g, x => {
|
||||
return `\\${x}`
|
||||
})
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import * as core from '@actions/core'
|
||||
|
||||
const defaultMaxAttempts = 3
|
||||
const defaultMinSeconds = 10
|
||||
const defaultMaxSeconds = 20
|
||||
|
||||
export class RetryHelper {
|
||||
private maxAttempts: number
|
||||
private minSeconds: number
|
||||
private maxSeconds: number
|
||||
|
||||
constructor(
|
||||
maxAttempts: number = defaultMaxAttempts,
|
||||
minSeconds: number = defaultMinSeconds,
|
||||
maxSeconds: number = defaultMaxSeconds
|
||||
) {
|
||||
this.maxAttempts = maxAttempts
|
||||
this.minSeconds = Math.floor(minSeconds)
|
||||
this.maxSeconds = Math.floor(maxSeconds)
|
||||
if (this.minSeconds > this.maxSeconds) {
|
||||
throw new Error('min seconds should be less than or equal to max seconds')
|
||||
}
|
||||
}
|
||||
|
||||
async execute<T>(action: () => Promise<T>): Promise<T> {
|
||||
let attempt = 1
|
||||
while (attempt < this.maxAttempts) {
|
||||
// Try
|
||||
try {
|
||||
return await action()
|
||||
} catch (err) {
|
||||
core.info(err.message)
|
||||
}
|
||||
|
||||
// Sleep
|
||||
const seconds = this.getSleepAmount()
|
||||
core.info(`Waiting ${seconds} seconds before trying again`)
|
||||
await this.sleep(seconds)
|
||||
attempt++
|
||||
}
|
||||
|
||||
// Last attempt
|
||||
return await action()
|
||||
}
|
||||
|
||||
private getSleepAmount(): number {
|
||||
return (
|
||||
Math.floor(Math.random() * (this.maxSeconds - this.minSeconds + 1)) +
|
||||
this.minSeconds
|
||||
)
|
||||
}
|
||||
|
||||
private async sleep(seconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, seconds * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute<T>(action: () => Promise<T>): Promise<T> {
|
||||
const retryHelper = new RetryHelper()
|
||||
return await retryHelper.execute(action)
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import * as coreCommand from '@actions/core/lib/command'
|
||||
|
||||
/**
|
||||
* Indicates whether the POST action is running
|
||||
*/
|
||||
export const IsPost = !!process.env['STATE_isPost']
|
||||
|
||||
/**
|
||||
* The repository path for the POST action. The value is empty during the MAIN action.
|
||||
*/
|
||||
export const RepositoryPath =
|
||||
(process.env['STATE_repositoryPath'] as string) || ''
|
||||
|
||||
/**
|
||||
* The SSH key path for the POST action. The value is empty during the MAIN action.
|
||||
*/
|
||||
export const SshKeyPath = (process.env['STATE_sshKeyPath'] as string) || ''
|
||||
|
||||
/**
|
||||
* The SSH known hosts path for the POST action. The value is empty during the MAIN action.
|
||||
*/
|
||||
export const SshKnownHostsPath =
|
||||
(process.env['STATE_sshKnownHostsPath'] as string) || ''
|
||||
|
||||
/**
|
||||
* Save the repository path so the POST action can retrieve the value.
|
||||
*/
|
||||
export function setRepositoryPath(repositoryPath: string) {
|
||||
coreCommand.issueCommand(
|
||||
'save-state',
|
||||
{name: 'repositoryPath'},
|
||||
repositoryPath
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the SSH key path so the POST action can retrieve the value.
|
||||
*/
|
||||
export function setSshKeyPath(sshKeyPath: string) {
|
||||
coreCommand.issueCommand('save-state', {name: 'sshKeyPath'}, sshKeyPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the SSH known hosts path so the POST action can retrieve the value.
|
||||
*/
|
||||
export function setSshKnownHostsPath(sshKnownHostsPath: string) {
|
||||
coreCommand.issueCommand(
|
||||
'save-state',
|
||||
{name: 'sshKnownHostsPath'},
|
||||
sshKnownHostsPath
|
||||
)
|
||||
}
|
||||
|
||||
// Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
|
||||
// This is necessary since we don't have a separate entry point.
|
||||
if (!IsPost) {
|
||||
coreCommand.issueCommand('save-state', {name: 'isPost'}, 'true')
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import * as assert from 'assert'
|
||||
import {IGitSourceSettings} from './git-source-settings'
|
||||
import {URL} from 'url'
|
||||
|
||||
export function getFetchUrl(settings: IGitSourceSettings): string {
|
||||
assert.ok(
|
||||
settings.repositoryOwner,
|
||||
'settings.repositoryOwner must be defined'
|
||||
)
|
||||
assert.ok(settings.repositoryName, 'settings.repositoryName must be defined')
|
||||
const serviceUrl = getServerUrl()
|
||||
const encodedOwner = encodeURIComponent(settings.repositoryOwner)
|
||||
const encodedName = encodeURIComponent(settings.repositoryName)
|
||||
if (settings.sshKey) {
|
||||
return `git@${serviceUrl.hostname}:${encodedOwner}/${encodedName}.git`
|
||||
}
|
||||
|
||||
// "origin" is SCHEME://HOSTNAME[:PORT]
|
||||
return `${serviceUrl.origin}/${encodedOwner}/${encodedName}`
|
||||
}
|
||||
|
||||
export function getServerUrl(): URL {
|
||||
// todo: remove GITHUB_URL after support for GHES Alpha is no longer needed
|
||||
return new URL(
|
||||
process.env['GITHUB_SERVER_URL'] ||
|
||||
process.env['GITHUB_URL'] ||
|
||||
'https://github.com'
|
||||
)
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"es6"
|
||||
],
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["__test__", "lib", "node_modules"]
|
||||
}
|
Loading…
Reference in New Issue