add support for submodules (#173)

This commit is contained in:
eric sciple 2020-03-05 14:21:59 -05:00 committed by GitHub
parent 204620207c
commit 422dc45671
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 915 additions and 220 deletions

@ -84,6 +84,35 @@ jobs:
shell: bash
run: __test__/verify-lfs.sh
# Submodules false
- name: Submodules false checkout
uses: ./
with:
ref: test-data/v2/submodule
path: submodules-false
- name: Verify submodules false
run: __test__/verify-submodules-false.sh
# Submodules one level
- name: Submodules true checkout
uses: ./
with:
ref: test-data/v2/submodule
path: submodules-true
submodules: true
- name: Verify submodules true
run: __test__/verify-submodules-true.sh
# Submodules recursive
- name: Submodules recursive checkout
uses: ./
with:
ref: test-data/v2/submodule
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'

@ -70,6 +70,11 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous
# Whether to download Git-LFS files
# Default: false
lfs: ''
# Whether to checkout submodules: `true` to checkout submodules or `recursive` to
# recursively checkout submodules.
# Default: false
submodules: ''
```
<!-- end usage -->

@ -8,10 +8,13 @@ import {IGitSourceSettings} from '../lib/git-source-settings'
const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper')
const originalRunnerTemp = process.env['RUNNER_TEMP']
const originalHome = process.env['HOME']
let workspace: string
let gitConfigPath: string
let localGitConfigPath: string
let globalGitConfigPath: string
let runnerTemp: string
let git: IGitCommandManager
let tempHomedir: string
let git: IGitCommandManager & {env: {[key: string]: string}}
let settings: IGitSourceSettings
describe('git-auth-helper tests', () => {
@ -23,11 +26,24 @@ describe('git-auth-helper tests', () => {
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())
})
afterEach(() => {
// Unregister mocks
jest.restoreAllMocks()
// Restore HOME
if (originalHome) {
process.env['HOME'] = originalHome
} else {
delete process.env['HOME']
}
})
afterAll(() => {
@ -38,10 +54,11 @@ describe('git-auth-helper tests', () => {
}
})
const configuresAuthHeader = 'configures auth header'
it(configuresAuthHeader, async () => {
const configureAuth_configuresAuthHeader =
'configureAuth configures auth header'
it(configureAuth_configuresAuthHeader, async () => {
// Arrange
await setup(configuresAuthHeader)
await setup(configureAuth_configuresAuthHeader)
expect(settings.authToken).toBeTruthy() // sanity check
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
@ -49,7 +66,9 @@ describe('git-auth-helper tests', () => {
await authHelper.configureAuth()
// Assert config
const configContent = (await fs.promises.readFile(gitConfigPath)).toString()
const configContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
const basicCredential = Buffer.from(
`x-access-token:${settings.authToken}`,
'utf8'
@ -61,11 +80,15 @@ describe('git-auth-helper tests', () => {
).toBeGreaterThanOrEqual(0)
})
const configuresAuthHeaderEvenWhenPersistCredentialsFalse =
'configures auth header even when persist credentials false'
it(configuresAuthHeaderEvenWhenPersistCredentialsFalse, async () => {
const configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse =
'configureAuth configures auth header even when persist credentials false'
it(
configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse,
async () => {
// Arrange
await setup(configuresAuthHeaderEvenWhenPersistCredentialsFalse)
await setup(
configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse
)
expect(settings.authToken).toBeTruthy() // sanity check
settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
@ -74,19 +97,22 @@ describe('git-auth-helper tests', () => {
await authHelper.configureAuth()
// Assert config
const configContent = (await fs.promises.readFile(gitConfigPath)).toString()
const configContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(
configContent.indexOf(
`http.https://github.com/.extraheader AUTHORIZATION`
)
).toBeGreaterThanOrEqual(0)
})
}
)
const registersBasicCredentialAsSecret =
'registers basic credential as secret'
it(registersBasicCredentialAsSecret, async () => {
const configureAuth_registersBasicCredentialAsSecret =
'configureAuth registers basic credential as secret'
it(configureAuth_registersBasicCredentialAsSecret, async () => {
// Arrange
await setup(registersBasicCredentialAsSecret)
await setup(configureAuth_registersBasicCredentialAsSecret)
expect(settings.authToken).toBeTruthy() // sanity check
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
@ -103,14 +129,139 @@ describe('git-auth-helper tests', () => {
expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret)
})
const removesToken = 'removes token'
it(removesToken, async () => {
const configureGlobalAuth_copiesGlobalGitConfig =
'configureGlobalAuth copies global git config'
it(configureGlobalAuth_copiesGlobalGitConfig, async () => {
// Arrange
await setup(removesToken)
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_doesNotConfigureTokenWhenPersistCredentialsFalse =
'configureSubmoduleAuth does not configure token when persist credentials false'
it(
configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse,
async () => {
// Arrange
await setup(
configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse
)
settings.persistCredentials = false
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls
// Act
await authHelper.configureSubmoduleAuth()
// Assert
expect(git.submoduleForeach).not.toHaveBeenCalled()
}
)
const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue =
'configureSubmoduleAuth configures token when persist credentials true'
it(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue,
async () => {
// Arrange
await setup(
configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue
)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls
// Act
await authHelper.configureSubmoduleAuth()
// Assert
expect(git.submoduleForeach).toHaveBeenCalledTimes(1)
}
)
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(gitConfigPath)
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check
@ -118,9 +269,37 @@ describe('git-auth-helper tests', () => {
await authHelper.removeAuth()
// Assert git config
gitConfigContent = (await fs.promises.readFile(gitConfigPath)).toString()
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> {
@ -129,14 +308,19 @@ async function setup(testName: string): Promise<void> {
// 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
gitConfigPath = path.join(workspace, '.git', 'config')
await fs.promises.mkdir(path.join(workspace, '.git'), {recursive: true})
await fs.promises.writeFile(path.join(workspace, '.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(),
@ -144,12 +328,20 @@ async function setup(testName: string): Promise<void> {
branchList: jest.fn(),
checkout: jest.fn(),
checkoutDetach: jest.fn(),
config: jest.fn(async (key: string, value: string) => {
await fs.promises.appendFile(gitConfigPath, `\n${key} ${value}`)
}),
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): Promise<boolean> => {
const content = await fs.promises.readFile(gitConfigPath)
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')
@ -157,6 +349,7 @@ async function setup(testName: string): Promise<void> {
return lines.some(x => x.startsWith(key))
}
),
env: {},
fetch: jest.fn(),
getWorkingDirectory: jest.fn(() => workspace),
init: jest.fn(),
@ -165,18 +358,29 @@ async function setup(testName: string): Promise<void> {
lfsInstall: jest.fn(),
log1: jest.fn(),
remoteAdd: jest.fn(),
setEnvironmentVariable: jest.fn(),
removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]),
setEnvironmentVariable: jest.fn((name: string, value: string) => {
git.env[name] = value
}),
submoduleForeach: jest.fn(async () => {
return ''
}),
submoduleSync: jest.fn(),
submoduleUpdate: jest.fn(),
tagExists: jest.fn(),
tryClean: jest.fn(),
tryConfigUnset: jest.fn(
async (key: string): Promise<boolean> => {
let content = await fs.promises.readFile(gitConfigPath)
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(gitConfigPath, lines.join('\n'))
await fs.promises.writeFile(configPath, lines.join('\n'))
return true
}
),
@ -191,6 +395,8 @@ async function setup(testName: string): Promise<void> {
commit: '',
fetchDepth: 1,
lfs: false,
submodules: false,
nestedSubmodules: false,
persistCredentials: true,
ref: 'refs/heads/master',
repositoryName: 'my-repo',

@ -363,7 +363,11 @@ async function setup(testName: string): Promise<void> {
lfsInstall: jest.fn(),
log1: jest.fn(),
remoteAdd: jest.fn(),
removeEnvironmentVariable: jest.fn(),
setEnvironmentVariable: jest.fn(),
submoduleForeach: jest.fn(),
submoduleSync: jest.fn(),
submoduleUpdate: jest.fn(),
tagExists: jest.fn(),
tryClean: jest.fn(async () => {
return true

@ -130,11 +130,4 @@ describe('input-helper tests', () => {
expect(settings.ref).toBe('refs/heads/some-other-ref')
expect(settings.commit).toBeFalsy()
})
it('gives good error message for submodules input', () => {
inputs.submodules = 'true'
assert.throws(() => {
inputHelper.getInputs()
}, /The input 'submodules' is not supported/)
})
})

@ -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

@ -1,11 +0,0 @@
#!/bin/bash
if [ ! -f "./submodules-not-checked-out/regular-file.txt" ]; then
echo "Expected regular file does not exist"
exit 1
fi
if [ -f "./submodules-not-checked-out/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

@ -30,6 +30,11 @@ inputs:
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.
default: false
runs:
using: node12
main: dist/index.js

245
dist/index.js vendored

@ -5074,21 +5074,35 @@ var __importStar = (this && this.__importStar) || function (mod) {
result["default"] = mod;
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const assert = __importStar(__webpack_require__(357));
const core = __importStar(__webpack_require__(470));
const fs = __importStar(__webpack_require__(747));
const io = __importStar(__webpack_require__(1));
const os = __importStar(__webpack_require__(87));
const path = __importStar(__webpack_require__(622));
const regexpHelper = __importStar(__webpack_require__(528));
const v4_1 = __importDefault(__webpack_require__(826));
const IS_WINDOWS = process.platform === 'win32';
const HOSTNAME = 'github.com';
const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader`;
function createAuthHelper(git, settings) {
return new GitAuthHelper(git, settings);
}
exports.createAuthHelper = createAuthHelper;
class GitAuthHelper {
constructor(gitCommandManager, gitSourceSettings) {
this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`;
this.temporaryHomePath = '';
this.git = gitCommandManager;
this.settings = gitSourceSettings || {};
// Token auth header
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}`;
}
configureAuth() {
return __awaiter(this, void 0, void 0, function* () {
@ -5098,37 +5112,110 @@ class GitAuthHelper {
yield this.configureToken();
});
}
configureGlobalAuth() {
return __awaiter(this, void 0, void 0, function* () {
// Create a temp home directory
const runnerTemp = process.env['RUNNER_TEMP'] || '';
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
const uniqueId = v4_1.default();
this.temporaryHomePath = path.join(runnerTemp, uniqueId);
yield 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 {
yield fs.promises.stat(gitConfigPath);
configExists = true;
}
catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
if (configExists) {
core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`);
yield io.cp(gitConfigPath, newGitConfigPath);
}
else {
yield fs.promises.writeFile(newGitConfigPath, '');
}
// Configure the token
try {
core.info(`Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`);
this.git.setEnvironmentVariable('HOME', this.temporaryHomePath);
yield this.configureToken(newGitConfigPath, 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.');
yield this.git.tryConfigUnset(this.tokenConfigKey, true);
throw err;
}
});
}
configureSubmoduleAuth() {
return __awaiter(this, void 0, void 0, function* () {
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 = yield this.git.submoduleForeach(`git config "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}" && git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules);
// Replace the placeholder
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
for (const configPath of configPaths) {
core.debug(`Replacing token placeholder in '${configPath}'`);
this.replaceTokenPlaceholder(configPath);
}
}
});
}
removeAuth() {
return __awaiter(this, void 0, void 0, function* () {
yield this.removeToken();
});
}
configureToken() {
removeGlobalAuth() {
return __awaiter(this, void 0, void 0, function* () {
core.info(`Unsetting HOME override`);
this.git.removeEnvironmentVariable('HOME');
yield io.rmRF(this.temporaryHomePath);
});
}
configureToken(configPath, globalConfig) {
return __awaiter(this, void 0, void 0, function* () {
// 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
const placeholder = `AUTHORIZATION: basic ***`;
yield this.git.config(EXTRA_HEADER_KEY, placeholder);
// Determine the basic credential value
const basicCredential = Buffer.from(`x-access-token:${this.settings.authToken}`, 'utf8').toString('base64');
core.setSecret(basicCredential);
// Replace the value in the config file
const configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config');
let content = (yield fs.promises.readFile(configPath)).toString();
const placeholderIndex = content.indexOf(placeholder);
if (placeholderIndex < 0 ||
placeholderIndex != content.lastIndexOf(placeholder)) {
throw new Error('Unable to replace auth placeholder in .git/config');
yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig);
// Replace the placeholder
yield this.replaceTokenPlaceholder(configPath || '');
});
}
content = content.replace(placeholder, `AUTHORIZATION: basic ${basicCredential}`);
replaceTokenPlaceholder(configPath) {
return __awaiter(this, void 0, void 0, function* () {
assert.ok(configPath, 'configPath is not defined');
let content = (yield 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);
yield fs.promises.writeFile(configPath, content);
});
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
// HTTP extra header
yield this.removeGitConfig(EXTRA_HEADER_KEY);
yield this.removeGitConfig(this.tokenConfigKey);
});
}
removeGitConfig(configKey) {
@ -5138,6 +5225,8 @@ class GitAuthHelper {
// Load the config contents
core.warning(`Failed to remove '${configKey}' from the git config`);
}
const pattern = regexpHelper.escape(configKey);
yield this.git.submoduleForeach(`git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, true);
});
}
}
@ -5172,6 +5261,7 @@ const exec = __importStar(__webpack_require__(986));
const fshelper = __importStar(__webpack_require__(618));
const io = __importStar(__webpack_require__(1));
const path = __importStar(__webpack_require__(622));
const regexpHelper = __importStar(__webpack_require__(528));
const retryHelper = __importStar(__webpack_require__(587));
const git_version_1 = __webpack_require__(559);
// Auth header not supported before 2.9
@ -5263,17 +5353,26 @@ class GitCommandManager {
yield this.execGit(args);
});
}
config(configKey, configValue) {
config(configKey, configValue, globalConfig) {
return __awaiter(this, void 0, void 0, function* () {
yield this.execGit(['config', '--local', configKey, configValue]);
yield this.execGit([
'config',
globalConfig ? '--global' : '--local',
configKey,
configValue
]);
});
}
configExists(configKey) {
configExists(configKey, globalConfig) {
return __awaiter(this, void 0, void 0, function* () {
const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => {
return `\\${x}`;
});
const output = yield this.execGit(['config', '--local', '--name-only', '--get-regexp', pattern], true);
const pattern = regexpHelper.escape(configKey);
const output = yield this.execGit([
'config',
globalConfig ? '--global' : '--local',
'--name-only',
'--get-regexp',
pattern
], true);
return output.exitCode === 0;
});
}
@ -5343,9 +5442,45 @@ class GitCommandManager {
yield this.execGit(['remote', 'add', remoteName, remoteUrl]);
});
}
removeEnvironmentVariable(name) {
delete this.gitEnv[name];
}
setEnvironmentVariable(name, value) {
this.gitEnv[name] = value;
}
submoduleForeach(command, recursive) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['submodule', 'foreach'];
if (recursive) {
args.push('--recursive');
}
args.push(command);
const output = yield this.execGit(args);
return output.stdout;
});
}
submoduleSync(recursive) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['submodule', 'sync'];
if (recursive) {
args.push('--recursive');
}
yield this.execGit(args);
});
}
submoduleUpdate(fetchDepth, recursive) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['-c', 'protocol.version=2'];
args.push('submodule', 'update', '--init', '--force');
if (fetchDepth > 0) {
args.push(`--depth=${fetchDepth}`);
}
if (recursive) {
args.push('--recursive');
}
yield this.execGit(args);
});
}
tagExists(pattern) {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['tag', '--list', pattern]);
@ -5358,9 +5493,14 @@ class GitCommandManager {
return output.exitCode === 0;
});
}
tryConfigUnset(configKey) {
tryConfigUnset(configKey, globalConfig) {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', '--unset-all', configKey], true);
const output = yield this.execGit([
'config',
globalConfig ? '--global' : '--local',
'--unset-all',
configKey
], true);
return output.exitCode === 0;
});
}
@ -5551,8 +5691,8 @@ function getSource(settings) {
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`);
yield githubApiHelper.downloadRepository(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit, settings.repositoryPath);
return;
}
else {
// Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath);
// Initialize the repository
@ -5585,6 +5725,25 @@ function getSource(settings) {
}
// Checkout
yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint);
// Submodules
if (settings.submodules) {
try {
// Temporarily override global config
yield authHelper.configureGlobalAuth();
// Checkout submodules
yield git.submoduleSync(settings.nestedSubmodules);
yield git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules);
yield git.submoduleForeach('git config --local gc.auto 0', settings.nestedSubmodules);
// Persist credentials
if (settings.persistCredentials) {
yield authHelper.configureSubmoduleAuth();
}
}
finally {
// Remove temporary global config override
yield authHelper.removeGlobalAuth();
}
}
// Dump some info about the checked out commit
yield git.log1();
}
@ -5594,7 +5753,6 @@ function getSource(settings) {
yield authHelper.removeAuth();
}
}
}
});
}
exports.getSource = getSource;
@ -9428,6 +9586,22 @@ module.exports.Singular = Hook.Singular
module.exports.Collection = Hook.Collection
/***/ }),
/***/ 528:
/***/ (function(__unusedmodule, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function escape(value) {
return value.replace(/[^a-zA-Z0-9_]/g, x => {
return `\\${x}`;
});
}
exports.escape = escape;
/***/ }),
/***/ 529:
@ -13731,10 +13905,6 @@ function getInputs() {
// Clean
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
core.debug(`clean = ${result.clean}`);
// Submodules
if (core.getInput('submodules')) {
throw new Error("The input 'submodules' is not supported in actions/checkout@v2");
}
// Fetch depth
result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'));
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
@ -13744,6 +13914,19 @@ function getInputs() {
// 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');
// Persist credentials

@ -5,6 +5,7 @@ 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 {default as uuid} from 'uuid/v4'
import {IGitCommandManager} from './git-command-manager'
@ -12,11 +13,13 @@ import {IGitSourceSettings} from './git-source-settings'
const IS_WINDOWS = process.platform === 'win32'
const HOSTNAME = 'github.com'
const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader`
export interface IGitAuthHelper {
configureAuth(): Promise<void>
configureGlobalAuth(): Promise<void>
configureSubmoduleAuth(): Promise<void>
removeAuth(): Promise<void>
removeGlobalAuth(): Promise<void>
}
export function createAuthHelper(
@ -27,8 +30,12 @@ export function createAuthHelper(
}
class GitAuthHelper {
private git: IGitCommandManager
private settings: IGitSourceSettings
private readonly git: IGitCommandManager
private readonly settings: IGitSourceSettings
private readonly tokenConfigKey: string = `http.https://${HOSTNAME}/.extraheader`
private readonly tokenPlaceholderConfigValue: string
private temporaryHomePath = ''
private tokenConfigValue: string
constructor(
gitCommandManager: IGitCommandManager,
@ -36,6 +43,15 @@ class GitAuthHelper {
) {
this.git = gitCommandManager
this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings)
// Token auth header
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}`
}
async configureAuth(): Promise<void> {
@ -46,48 +62,132 @@ class GitAuthHelper {
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, '')
}
// Configure the token
try {
core.info(
`Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`
)
this.git.setEnvironmentVariable('HOME', this.temporaryHomePath)
await this.configureToken(newGitConfigPath, 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> {
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 "${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)
}
}
}
async removeAuth(): Promise<void> {
await this.removeToken()
}
private async configureToken(): Promise<void> {
async removeGlobalAuth(): Promise<void> {
core.info(`Unsetting HOME override`)
this.git.removeEnvironmentVariable('HOME')
await io.rmRF(this.temporaryHomePath)
}
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
const placeholder = `AUTHORIZATION: basic ***`
await this.git.config(EXTRA_HEADER_KEY, placeholder)
// Determine the basic credential value
const basicCredential = Buffer.from(
`x-access-token:${this.settings.authToken}`,
'utf8'
).toString('base64')
core.setSecret(basicCredential)
// Replace the value in the config file
const configPath = path.join(
this.git.getWorkingDirectory(),
'.git',
'config'
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(placeholder)
const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
if (
placeholderIndex < 0 ||
placeholderIndex != content.lastIndexOf(placeholder)
placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
) {
throw new Error('Unable to replace auth placeholder in .git/config')
throw new Error(`Unable to replace auth placeholder in ${configPath}`)
}
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
content = content.replace(
placeholder,
`AUTHORIZATION: basic ${basicCredential}`
this.tokenPlaceholderConfigValue,
this.tokenConfigValue
)
await fs.promises.writeFile(configPath, content)
}
private async removeToken(): Promise<void> {
// HTTP extra header
await this.removeGitConfig(EXTRA_HEADER_KEY)
await this.removeGitConfig(this.tokenConfigKey)
}
private async removeGitConfig(configKey: string): Promise<void> {
@ -98,5 +198,11 @@ class GitAuthHelper {
// 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
)
}
}

@ -3,6 +3,7 @@ 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 regexpHelper from './regexp-helper'
import * as retryHelper from './retry-helper'
import {GitVersion} from './git-version'
@ -16,8 +17,12 @@ export interface IGitCommandManager {
branchList(remote: boolean): Promise<string[]>
checkout(ref: string, startPoint: string): Promise<void>
checkoutDetach(): Promise<void>
config(configKey: string, configValue: string): Promise<void>
configExists(configKey: string): Promise<boolean>
config(
configKey: string,
configValue: string,
globalConfig?: boolean
): Promise<void>
configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
fetch(fetchDepth: number, refSpec: string[]): Promise<void>
getWorkingDirectory(): string
init(): Promise<void>
@ -26,10 +31,14 @@ export interface IGitCommandManager {
lfsInstall(): Promise<void>
log1(): Promise<void>
remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
removeEnvironmentVariable(name: string): void
setEnvironmentVariable(name: string, value: string): void
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): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryReset(): Promise<boolean>
@ -124,16 +133,32 @@ class GitCommandManager {
await this.execGit(args)
}
async config(configKey: string, configValue: string): Promise<void> {
await this.execGit(['config', '--local', configKey, configValue])
async config(
configKey: string,
configValue: string,
globalConfig?: boolean
): Promise<void> {
await this.execGit([
'config',
globalConfig ? '--global' : '--local',
configKey,
configValue
])
}
async configExists(configKey: string): Promise<boolean> {
const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => {
return `\\${x}`
})
async configExists(
configKey: string,
globalConfig?: boolean
): Promise<boolean> {
const pattern = regexpHelper.escape(configKey)
const output = await this.execGit(
['config', '--local', '--name-only', '--get-regexp', pattern],
[
'config',
globalConfig ? '--global' : '--local',
'--name-only',
'--get-regexp',
pattern
],
true
)
return output.exitCode === 0
@ -208,10 +233,48 @@ class GitCommandManager {
await this.execGit(['remote', 'add', remoteName, remoteUrl])
}
removeEnvironmentVariable(name: string): void {
delete this.gitEnv[name]
}
setEnvironmentVariable(name: string, value: string): void {
this.gitEnv[name] = value
}
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()
@ -222,9 +285,17 @@ class GitCommandManager {
return output.exitCode === 0
}
async tryConfigUnset(configKey: string): Promise<boolean> {
async tryConfigUnset(
configKey: string,
globalConfig?: boolean
): Promise<boolean> {
const output = await this.execGit(
['config', '--local', '--unset-all', configKey],
[
'config',
globalConfig ? '--global' : '--local',
'--unset-all',
configKey
],
true
)
return output.exitCode === 0

@ -61,7 +61,9 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
settings.commit,
settings.repositoryPath
)
} else {
return
}
// Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath)
@ -111,6 +113,33 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Checkout
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
// Submodules
if (settings.submodules) {
try {
// Temporarily override global config
await authHelper.configureGlobalAuth()
// Checkout submodules
await git.submoduleSync(settings.nestedSubmodules)
await git.submoduleUpdate(
settings.fetchDepth,
settings.nestedSubmodules
)
await git.submoduleForeach(
'git config --local gc.auto 0',
settings.nestedSubmodules
)
// Persist credentials
if (settings.persistCredentials) {
await authHelper.configureSubmoduleAuth()
}
} finally {
// Remove temporary global config override
await authHelper.removeGlobalAuth()
}
}
// Dump some info about the checked out commit
await git.log1()
} finally {
@ -119,7 +148,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
await authHelper.removeAuth()
}
}
}
}
export async function cleanup(repositoryPath: string): Promise<void> {

@ -7,6 +7,8 @@ export interface IGitSourceSettings {
clean: boolean
fetchDepth: number
lfs: boolean
submodules: boolean
nestedSubmodules: boolean
authToken: string
persistCredentials: boolean
}

@ -85,13 +85,6 @@ export function getInputs(): IGitSourceSettings {
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
core.debug(`clean = ${result.clean}`)
// Submodules
if (core.getInput('submodules')) {
throw new Error(
"The input 'submodules' is not supported in actions/checkout@v2"
)
}
// Fetch depth
result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'))
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
@ -103,6 +96,19 @@ export function getInputs(): IGitSourceSettings {
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')

5
src/regexp-helper.ts Normal file

@ -0,0 +1,5 @@
export function escape(value: string): string {
return value.replace(/[^a-zA-Z0-9_]/g, x => {
return `\\${x}`
})
}