diff options
| author | eric sciple <ericsciple@users.noreply.github.com> | 2019-12-03 10:28:59 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-12-03 10:28:59 -0500 |
| commit | e404ba5989baa550f631fb1262539d1375a856b7 (patch) | |
| tree | 4cc911589e7c9a4c976bc99eb4dcb886d2b2d8a8 /src | |
| parent | 6a11491f2eea6739d1296d604c5402cffa43a1f1 (diff) | |
Convert checkout to a regular action (#70)
Diffstat (limited to 'src')
| -rw-r--r-- | src/fs-helper.ts | 77 | ||||
| -rw-r--r-- | src/git-command-manager.ts | 399 | ||||
| -rw-r--r-- | src/git-source-provider.ts | 246 | ||||
| -rw-r--r-- | src/git-version.ts | 77 | ||||
| -rw-r--r-- | src/input-helper.ts | 104 | ||||
| -rw-r--r-- | src/main.ts | 47 | ||||
| -rw-r--r-- | src/misc/generate-docs.ts | 102 | ||||
| -rw-r--r-- | src/ref-helper.ts | 109 |
8 files changed, 1161 insertions, 0 deletions
diff --git a/src/fs-helper.ts b/src/fs-helper.ts new file mode 100644 index 0000000..c443404 --- /dev/null +++ b/src/fs-helper.ts @@ -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 +} diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts new file mode 100644 index 0000000..6e4f22f --- /dev/null +++ b/src/git-command-manager.ts @@ -0,0 +1,399 @@ +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 {GitVersion} from './git-version' + +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): Promise<void> + configExists(configKey: string): Promise<boolean> + fetch(fetchDepth: number, refSpec: string[]): Promise<void> + getWorkingDirectory(): string + init(): Promise<void> + isDetached(): Promise<boolean> + lfsFetch(ref: string): Promise<void> + lfsInstall(): Promise<void> + log1(): Promise<void> + remoteAdd(remoteName: string, remoteUrl: string): Promise<void> + tagExists(pattern: string): Promise<boolean> + tryClean(): Promise<boolean> + tryConfigUnset(configKey: string): 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" because the output from + // "branch --list" is more difficult when in a detached HEAD state. + + const args = ['rev-parse', '--symbolic'] + 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) { + 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): Promise<void> { + await this.execGit(['config', configKey, configValue]) + } + + async configExists(configKey: string): Promise<boolean> { + const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => { + return `\\${x}` + }) + const output = await this.execGit( + ['config', '--name-only', '--get-regexp', pattern], + true + ) + return output.exitCode === 0 + } + + async fetch(fetchDepth: number, refSpec: string[]): Promise<void> { + const args = [ + '-c', + 'protocol.version=2', + 'fetch', + '--no-tags', + '--prune', + '--progress', + '--no-recurse-submodules' + ] + if (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) + } + + let attempt = 1 + const maxAttempts = 3 + while (attempt <= maxAttempts) { + const allowAllExitCodes = attempt < maxAttempts + const output = await this.execGit(args, allowAllExitCodes) + if (output.exitCode === 0) { + break + } + + const seconds = this.getRandomIntInclusive(1, 10) + core.warning( + `Git fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.` + ) + await this.sleep(seconds * 1000) + attempt++ + } + } + + getWorkingDirectory(): string { + return this.workingDirectory + } + + async init(): Promise<void> { + await this.execGit(['init', this.workingDirectory]) + } + + async isDetached(): Promise<boolean> { + // Note, this implementation uses "branch --show-current" because + // "rev-parse --symbolic-full-name HEAD" can fail on a new repo + // with nothing checked out. + + const output = await this.execGit(['branch', '--show-current']) + return output.stdout.trim() === '' + } + + async lfsFetch(ref: string): Promise<void> { + const args = ['lfs', 'fetch', 'origin', ref] + + let attempt = 1 + const maxAttempts = 3 + while (attempt <= maxAttempts) { + const allowAllExitCodes = attempt < maxAttempts + const output = await this.execGit(args, allowAllExitCodes) + if (output.exitCode === 0) { + break + } + + const seconds = this.getRandomIntInclusive(1, 10) + core.warning( + `Git lfs fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.` + ) + await this.sleep(seconds * 1000) + attempt++ + } + } + + async lfsInstall(): Promise<void> { + await this.execGit(['lfs', 'install', '--local']) + } + + async log1(): Promise<void> { + await this.execGit(['log', '-1']) + } + + async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> { + await this.execGit(['remote', 'add', remoteName, remoteUrl]) + } + + 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): Promise<boolean> { + const output = await this.execGit( + ['config', '--unset-all', configKey], + true + ) + return output.exitCode === 0 + } + + async tryDisableAutomaticGarbageCollection(): Promise<boolean> { + const output = await this.execGit(['config', 'gc.auto', '0'], true) + return output.exitCode === 0 + } + + async tryGetFetchUrl(): Promise<string> { + const output = await this.execGit( + ['config', '--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 + // Note: + // - Auth header not supported before 2.9 + // - Wire protocol v2 not supported before 2.18 + const minimumGitVersion = new GitVersion('2.18') + 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 + } + + private getRandomIntInclusive(minimum: number, maximum: number): number { + minimum = Math.floor(minimum) + maximum = Math.floor(maximum) + return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum + } + + private async sleep(milliseconds): Promise<void> { + return new Promise(resolve => setTimeout(resolve, milliseconds)) + } +} + +class GitOutput { + stdout = '' + exitCode = 0 +} diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts new file mode 100644 index 0000000..0752100 --- /dev/null +++ b/src/git-source-provider.ts @@ -0,0 +1,246 @@ +import * as core from '@actions/core' +import * as coreCommand from '@actions/core/lib/command' +import * as fs from 'fs' +import * as fsHelper from './fs-helper' +import * as gitCommandManager from './git-command-manager' +import * as io from '@actions/io' +import * as path from 'path' +import * as refHelper from './ref-helper' +import {IGitCommandManager} from './git-command-manager' + +const authConfigKey = `http.https://github.com/.extraheader` + +export interface ISourceSettings { + repositoryPath: string + repositoryOwner: string + repositoryName: string + ref: string + commit: string + clean: boolean + fetchDepth: number + lfs: boolean + accessToken: string +} + +export async function getSource(settings: ISourceSettings): Promise<void> { + core.info( + `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` + ) + const repositoryUrl = `https://github.com/${encodeURIComponent( + settings.repositoryOwner + )}/${encodeURIComponent(settings.repositoryName)}` + + // 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.info(`Working directory is '${settings.repositoryPath}'`) + const git = await gitCommandManager.CreateCommandManager( + settings.repositoryPath, + settings.lfs + ) + + // Try prepare existing directory, otherwise recreate + if ( + isExisting && + !(await tryPrepareExistingDirectory( + git, + settings.repositoryPath, + repositoryUrl, + settings.clean + )) + ) { + await io.rmRF(settings.repositoryPath) + await io.mkdirP(settings.repositoryPath) + } + + // Initialize the repository + if ( + !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) + ) { + await git.init() + await git.remoteAdd('origin', repositoryUrl) + } + + // Disable 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.` + ) + } + + // Remove possible previous extraheader + await removeGitConfig(git, authConfigKey) + + // Add extraheader (auth) + const base64Credentials = Buffer.from( + `x-access-token:${settings.accessToken}`, + 'utf8' + ).toString('base64') + core.setSecret(base64Credentials) + const authConfigValue = `AUTHORIZATION: basic ${base64Credentials}` + await git.config(authConfigKey, authConfigValue) + + // LFS install + if (settings.lfs) { + await git.lfsInstall() + } + + // Fetch + const refSpec = refHelper.getRefSpec(settings.ref, settings.commit) + await git.fetch(settings.fetchDepth, refSpec) + + // Checkout info + const checkoutInfo = await refHelper.getCheckoutInfo( + git, + settings.ref, + settings.commit + ) + + // 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) { + await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref) + } + + // Checkout + await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) + + // Dump some info about the checked out commit + await git.log1() + + // Set intra-task state for cleanup + coreCommand.issueCommand( + 'save-state', + {name: 'repositoryPath'}, + settings.repositoryPath + ) +} + +export async function cleanup(repositoryPath: string): Promise<void> { + // Repo exists? + if (!fsHelper.fileExistsSync(path.join(repositoryPath, '.git', 'config'))) { + return + } + fsHelper.directoryExistsSync(repositoryPath, true) + + // Remove the config key + const git = await gitCommandManager.CreateCommandManager( + repositoryPath, + false + ) + await removeGitConfig(git, authConfigKey) +} + +async function tryPrepareExistingDirectory( + git: IGitCommandManager, + repositoryPath: string, + repositoryUrl: string, + clean: boolean +): Promise<boolean> { + // Fetch URL does not match + if ( + !fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')) || + repositoryUrl !== (await git.tryGetFetchUrl()) + ) { + return false + } + + // 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 { + // 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 all refs/remotes/origin/* to avoid conflicts + branches = await git.branchList(true) + for (const branch of branches) { + await git.branchDelete(true, branch) + } + } catch (error) { + core.warning( + `Unable to prepare the existing repository. The repository will be recreated instead.` + ) + return false + } + + // Clean + if (clean) { + let succeeded = true + 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}'.` + ) + succeeded = false + } else if (!(await git.tryReset())) { + succeeded = false + } + + if (!succeeded) { + core.warning( + `Unable to clean or reset the repository. The repository will be recreated instead.` + ) + } + + return succeeded + } + + return true +} + +async function removeGitConfig( + git: IGitCommandManager, + configKey: string +): Promise<void> { + if ( + (await git.configExists(configKey)) && + !(await git.tryConfigUnset(configKey)) + ) { + // Load the config contents + core.warning( + `Failed to remove '${configKey}' from the git config. Attempting to remove the config value by editing the file directly.` + ) + const configPath = path.join(git.getWorkingDirectory(), '.git', 'config') + fsHelper.fileExistsSync(configPath) + let contents = fs.readFileSync(configPath).toString() || '' + + // Filter - only includes lines that do not contain the config key + const upperConfigKey = configKey.toUpperCase() + const split = contents + .split('\n') + .filter(x => !x.toUpperCase().includes(upperConfigKey)) + contents = split.join('\n') + + // Rewrite the config file + fs.writeFileSync(configPath, contents) + } +} diff --git a/src/git-version.ts b/src/git-version.ts new file mode 100644 index 0000000..44bee1a --- /dev/null +++ b/src/git-version.ts @@ -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 + } +} diff --git a/src/input-helper.ts b/src/input-helper.ts new file mode 100644 index 0000000..d7d8779 --- /dev/null +++ b/src/input-helper.ts @@ -0,0 +1,104 @@ +import * as core from '@actions/core' +import * as fsHelper from './fs-helper' +import * as github from '@actions/github' +import * as path from 'path' +import {ISourceSettings} from './git-source-provider' + +export function getInputs(): ISourceSettings { + const result = ({} as unknown) as ISourceSettings + + // 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 + } + + if (!result.ref && !result.commit) { + result.ref = 'refs/heads/master' + } + } + // 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}`) + + // 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) { + result.fetchDepth = 0 + } + core.debug(`fetch depth = ${result.fetchDepth}`) + + // LFS + result.lfs = (core.getInput('lfs') || 'false').toUpperCase() === 'TRUE' + core.debug(`lfs = ${result.lfs}`) + + // Access token + result.accessToken = core.getInput('token') + + return result +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..26da3aa --- /dev/null +++ b/src/main.ts @@ -0,0 +1,47 @@ +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' + +const cleanupRepositoryPath = process.env['STATE_repositoryPath'] as string + +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(cleanupRepositoryPath) + } catch (error) { + core.warning(error.message) + } +} + +// Main +if (!cleanupRepositoryPath) { + run() +} +// Post +else { + cleanup() +} diff --git a/src/misc/generate-docs.ts b/src/misc/generate-docs.ts new file mode 100644 index 0000000..c882c55 --- /dev/null +++ b/src/misc/generate-docs.ts @@ -0,0 +1,102 @@ +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 + 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 = segment.substr(0, segment.length - 1) + } + } else { + segment = description + } + + description = description.substr(segment.length) // Remaining + segment = segment.trimRight() // Trim the trailing space + newReadme.push(` # ${segment}`) + } + + // Input and default + if (input.default !== undefined) { + newReadme.push(` # Default: ${input.default}`) + } + 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@preview', + path.join(__dirname, '..', '..', 'action.yml'), + path.join(__dirname, '..', '..', 'README.md') +) diff --git a/src/ref-helper.ts b/src/ref-helper.ts new file mode 100644 index 0000000..ff256af --- /dev/null +++ b/src/ref-helper.ts @@ -0,0 +1,109 @@ +import {IGitCommandManager} from './git-command-manager' + +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 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}`] + } +} |
