Convert checkout to a regular action (#70)

This commit is contained in:
eric sciple
2019-12-03 10:28:59 -05:00
committed by GitHub
parent 50fbc622fc
commit e347bba93b
33 changed files with 22256 additions and 62 deletions

77
src/fs-helper.ts Normal file
View File

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

399
src/git-command-manager.ts Normal file
View File

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

246
src/git-source-provider.ts Normal file
View File

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

77
src/git-version.ts Normal file
View File

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

104
src/input-helper.ts Normal file
View File

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

47
src/main.ts Normal file
View File

@ -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()
}

102
src/misc/generate-docs.ts Normal file
View File

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

109
src/ref-helper.ts Normal file
View File

@ -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}`]
}
}