diff --git a/.gitignore b/.gitignore index 66388b44..40c80f4a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ yarn-error.log* #node_modules # Build -lib +# lib ## Registry package-lock.json diff --git a/lib/constants.d.ts b/lib/constants.d.ts new file mode 100644 index 00000000..d6409cfe --- /dev/null +++ b/lib/constants.d.ts @@ -0,0 +1,43 @@ +export interface actionInterface { + /** Deployment access token. */ + accessToken?: string | null; + /** The base branch that the deploy should be made from. */ + baseBranch?: string; + /** The branch that the action should deploy to. */ + branch: string; + /** If your project generates hashed files on build you can use this option to automatically delete them from the deployment branch with each deploy. This option can be toggled on by setting it to true. */ + clean?: string | boolean; + /** If you need to use CLEAN but you'd like to preserve certain files or folders you can use this option. */ + cleanExclude?: string | Array; + /** If you need to customize the commit message for an integration you can do so. */ + commitMessage?: string; + /** Unhides the Git commands from the function terminal. */ + debug?: boolean | string; + /** The default branch of the deployment. Similar to baseBranch if you're using this action as a module. */ + defaultBranch?: string; + /** The git config email. */ + email?: string; + /** The folder to deploy. */ + folder: string; + /** GitHub deployment token. */ + gitHubToken?: string | null; + /** Determines if the action is running in test mode or not. */ + isTest?: string | undefined | null; + /** The git config name. */ + name?: string; + /** The repository path, for example JamesIves/github-pages-deploy-action */ + repositoryName?: string; + /** The fully qualified repositpory path, this gets auto generated if repositoryName is provided. */ + repositoryPath?: string; + /** The root directory where your project lives. */ + root?: string; + /** Set to true if you're using an ssh client in your build step. */ + ssh?: string | boolean | null; + /** If you'd like to push the contents of the deployment folder into a specific directory on the deployment branch you can specify it here. */ + targetFolder?: string; + /** The token type, ie ssh/github token/access token, this gets automatically generated. */ + tokenType?: string; + /** The folder where your deployment project lives. */ + workspace: string; +} +export declare const action: actionInterface; diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 00000000..4aa92d45 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,49 @@ +"use strict"; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@actions/core"); +const github = __importStar(require("@actions/github")); +const util_1 = require("./util"); +const { pusher, repository } = github.context.payload; +/* Required action data that gets initialized when running within the GitHub Actions environment. */ +exports.action = { + accessToken: core_1.getInput("ACCESS_TOKEN"), + baseBranch: core_1.getInput("BASE_BRANCH"), + folder: core_1.getInput("FOLDER"), + branch: core_1.getInput("BRANCH"), + commitMessage: core_1.getInput("COMMIT_MESSAGE"), + clean: core_1.getInput("CLEAN"), + cleanExclude: core_1.getInput("CLEAN_EXCLUDE"), + debug: core_1.getInput("DEBUG"), + defaultBranch: process.env.GITHUB_SHA ? process.env.GITHUB_SHA : "master", + isTest: process.env.UNIT_TEST, + ssh: core_1.getInput("SSH"), + email: !util_1.isNullOrUndefined(core_1.getInput("GIT_CONFIG_EMAIL")) + ? core_1.getInput("GIT_CONFIG_EMAIL") + : pusher && pusher.email + ? pusher.email + : `${process.env.GITHUB_ACTOR || + "github-pages-deploy-action"}@users.noreply.github.com`, + gitHubToken: core_1.getInput("GITHUB_TOKEN"), + name: !util_1.isNullOrUndefined(core_1.getInput("GIT_CONFIG_NAME")) + ? core_1.getInput("GIT_CONFIG_NAME") + : pusher && pusher.name + ? pusher.name + : process.env.GITHUB_ACTOR + ? process.env.GITHUB_ACTOR + : "GitHub Pages Deploy Action", + repositoryName: !util_1.isNullOrUndefined(core_1.getInput("REPOSITORY_NAME")) + ? core_1.getInput("REPOSITORY_NAME") + : repository && repository.full_name + ? repository.full_name + : process.env.GITHUB_REPOSITORY, + root: ".", + targetFolder: core_1.getInput("TARGET_FOLDER"), + workspace: process.env.GITHUB_WORKSPACE || "" +}; diff --git a/lib/execute.d.ts b/lib/execute.d.ts new file mode 100644 index 00000000..2c1acda8 --- /dev/null +++ b/lib/execute.d.ts @@ -0,0 +1,8 @@ +/** Wrapper around the GitHub toolkit exec command which returns the output. + * Also allows you to easily toggle the current working directory. + * @param cmd = The command to execute. + * @param cwd - The current working directory. + * @returns - The output from the command. + */ +export declare function execute(cmd: string, cwd: string): Promise; +export declare function stdout(data: any): void; diff --git a/lib/execute.js b/lib/execute.js new file mode 100644 index 00000000..ff879150 --- /dev/null +++ b/lib/execute.js @@ -0,0 +1,38 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const exec_1 = require("@actions/exec"); +let output; +/** Wrapper around the GitHub toolkit exec command which returns the output. + * Also allows you to easily toggle the current working directory. + * @param cmd = The command to execute. + * @param cwd - The current working directory. + * @returns - The output from the command. + */ +function execute(cmd, cwd) { + return __awaiter(this, void 0, void 0, function* () { + output = ""; + yield exec_1.exec(cmd, [], { + // Silences the input unless the INPUT_DEBUG flag is set. + silent: process.env.DEBUG_DEPLOY_ACTION ? false : true, + cwd, + listeners: { + stdout + } + }); + return Promise.resolve(output); + }); +} +exports.execute = execute; +function stdout(data) { + output += data.toString().trim(); +} +exports.stdout = stdout; diff --git a/lib/git.d.ts b/lib/git.d.ts new file mode 100644 index 00000000..5f502d79 --- /dev/null +++ b/lib/git.d.ts @@ -0,0 +1,5 @@ +import { actionInterface } from "./constants"; +export declare function init(action: actionInterface): Promise; +export declare function switchToBaseBranch(action: actionInterface): Promise; +export declare function generateBranch(action: actionInterface): Promise; +export declare function deploy(action: actionInterface): Promise; diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 00000000..28fe5571 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,138 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const execute_1 = require("./execute"); +const util_1 = require("./util"); +/* Initializes git in the workspace. */ +function init(action) { + return __awaiter(this, void 0, void 0, function* () { + try { + util_1.hasRequiredParameters(action); + console.log(`Deploying using ${action.tokenType}... 🔑`); + console.log("Configuring git..."); + yield execute_1.execute(`git init`, action.workspace); + yield execute_1.execute(`git config user.name "${action.name}"`, action.workspace); + yield execute_1.execute(`git config user.email "${action.email}"`, action.workspace); + yield execute_1.execute(`git remote rm origin`, action.workspace); + yield execute_1.execute(`git remote add origin ${action.repositoryPath}`, action.workspace); + yield execute_1.execute(`git fetch`, action.workspace); + console.log("Git configured... 🔧"); + } + catch (error) { + throw new Error(`There was an error initializing the repository: ${util_1.suppressSensitiveInformation(error.message, action)} ❌`); + } + }); +} +exports.init = init; +/* Switches to the base branch. */ +function switchToBaseBranch(action) { + return __awaiter(this, void 0, void 0, function* () { + try { + util_1.hasRequiredParameters(action); + yield execute_1.execute(`git checkout --progress --force ${action.baseBranch ? action.baseBranch : action.defaultBranch}`, action.workspace); + } + catch (error) { + throw new Error(`There was an error switching to the base branch: ${util_1.suppressSensitiveInformation(error.message, action)} ❌`); + } + }); +} +exports.switchToBaseBranch = switchToBaseBranch; +/* Generates the branch if it doesn't exist on the remote. */ +function generateBranch(action) { + return __awaiter(this, void 0, void 0, function* () { + try { + util_1.hasRequiredParameters(action); + console.log(`Creating the ${action.branch} branch...`); + yield switchToBaseBranch(action); + yield execute_1.execute(`git checkout --orphan ${action.branch}`, action.workspace); + yield execute_1.execute(`git reset --hard`, action.workspace); + yield execute_1.execute(`git commit --allow-empty -m "Initial ${action.branch} commit."`, action.workspace); + yield execute_1.execute(`git push ${action.repositoryPath} ${action.branch}`, action.workspace); + yield execute_1.execute(`git fetch`, action.workspace); + console.log(`Created the ${action.branch} branch... 🔧`); + } + catch (error) { + throw new Error(`There was an error creating the deployment branch: ${util_1.suppressSensitiveInformation(error.message, action)} ❌`); + } + }); +} +exports.generateBranch = generateBranch; +/* Runs the necessary steps to make the deployment. */ +function deploy(action) { + return __awaiter(this, void 0, void 0, function* () { + const temporaryDeploymentDirectory = "gh-action-temp-deployment-folder"; + const temporaryDeploymentBranch = "gh-action-temp-deployment-branch"; + console.log("Starting to commit changes..."); + try { + util_1.hasRequiredParameters(action); + /* + Checks to see if the remote exists prior to deploying. + If the branch doesn't exist it gets created here as an orphan. + */ + const branchExists = yield execute_1.execute(`git ls-remote --heads ${action.repositoryPath} ${action.branch} | wc -l`, action.workspace); + if (!branchExists && !action.isTest) { + yield generateBranch(action); + } + // Checks out the base branch to begin the deployment process. + yield switchToBaseBranch(action); + yield execute_1.execute(`git fetch ${action.repositoryPath}`, action.workspace); + yield execute_1.execute(`git worktree add --checkout ${temporaryDeploymentDirectory} origin/${action.branch}`, action.workspace); + // Ensures that items that need to be excluded from the clean job get parsed. + let excludes = ""; + if (action.clean && action.cleanExclude) { + try { + const excludedItems = typeof action.cleanExclude === "string" + ? JSON.parse(action.cleanExclude) + : action.cleanExclude; + excludedItems.forEach((item) => (excludes += `--exclude ${item} `)); + } + catch (_a) { + console.log("There was an error parsing your CLEAN_EXCLUDE items. Please refer to the README for more details. ❌"); + } + } + /* + Pushes all of the build files into the deployment directory. + Allows the user to specify the root if '.' is provided. + rsync is used to prevent file duplication. */ + yield execute_1.execute(`rsync -q -av --progress ${action.folder}/. ${action.targetFolder + ? `${temporaryDeploymentDirectory}/${action.targetFolder}` + : temporaryDeploymentDirectory} ${action.clean + ? `--delete ${excludes} --exclude CNAME --exclude .nojekyll` + : ""} --exclude .ssh --exclude .git --exclude .github ${action.folder === action.root + ? `--exclude ${temporaryDeploymentDirectory}` + : ""}`, action.workspace); + const hasFilesToCommit = yield execute_1.execute(`git status --porcelain`, `${action.workspace}/${temporaryDeploymentDirectory}`); + if (!hasFilesToCommit && !action.isTest) { + console.log("There is nothing to commit. Exiting early... 📭"); + return; + } + // Commits to GitHub. + yield execute_1.execute(`git add --all .`, `${action.workspace}/${temporaryDeploymentDirectory}`); + yield execute_1.execute(`git checkout -b ${temporaryDeploymentBranch}`, `${action.workspace}/${temporaryDeploymentDirectory}`); + yield execute_1.execute(`git commit -m "${!util_1.isNullOrUndefined(action.commitMessage) + ? action.commitMessage + : `Deploying to ${action.branch} from ${action.baseBranch}`} ${process.env.GITHUB_SHA ? `- ${process.env.GITHUB_SHA}` : ""} 🚀" --quiet`, `${action.workspace}/${temporaryDeploymentDirectory}`); + yield execute_1.execute(`git push --force ${action.repositoryPath} ${temporaryDeploymentBranch}:${action.branch}`, `${action.workspace}/${temporaryDeploymentDirectory}`); + console.log(`Changes committed to the ${action.branch} branch... 📦`); + // Cleans up temporary files/folders and restores the git state. + console.log("Running post deployment cleanup jobs..."); + yield execute_1.execute(`git checkout --progress --force ${action.defaultBranch}`, action.workspace); + } + catch (error) { + throw new Error(`The deploy step encountered an error: ${util_1.suppressSensitiveInformation(error.message, action)} ❌`); + } + finally { + // Ensures the deployment directory is safely removed. + yield execute_1.execute(`rm -rf ${temporaryDeploymentDirectory}`, action.workspace); + } + }); +} +exports.deploy = deploy; diff --git a/lib/lib.d.ts b/lib/lib.d.ts new file mode 100644 index 00000000..e580eb81 --- /dev/null +++ b/lib/lib.d.ts @@ -0,0 +1,5 @@ +import { actionInterface } from "./constants"; +import { deploy, generateBranch, init } from "./git"; +/** Initializes and runs the action. */ +export default function run(configuration: actionInterface): Promise; +export { init, deploy, generateBranch, actionInterface }; diff --git a/lib/lib.js b/lib/lib.js new file mode 100644 index 00000000..5037d387 --- /dev/null +++ b/lib/lib.js @@ -0,0 +1,47 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@actions/core"); +const constants_1 = require("./constants"); +const git_1 = require("./git"); +exports.deploy = git_1.deploy; +exports.generateBranch = git_1.generateBranch; +exports.init = git_1.init; +const util_1 = require("./util"); +/** Initializes and runs the action. */ +function run(configuration) { + return __awaiter(this, void 0, void 0, function* () { + let errorState = false; + try { + console.log("Checking configuration and starting deployment...🚦"); + const settings = Object.assign(Object.assign({}, constants_1.action), configuration); + // Defines the repository paths and token types. + settings.repositoryPath = util_1.generateRepositoryPath(settings); + settings.tokenType = util_1.generateTokenType(settings); + if (settings.debug) { + // Sets the debug flag if passed as an arguement. + core_1.exportVariable("DEBUG_DEPLOY_ACTION", "debug"); + } + yield git_1.init(settings); + yield git_1.deploy(settings); + } + catch (error) { + errorState = true; + core_1.setFailed(error.message); + } + finally { + console.log(`${errorState + ? "Deployment Failed ❌" + : "Completed Deployment Successfully! ✅"}`); + } + }); +} +exports.default = run; diff --git a/lib/main.d.ts b/lib/main.d.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/lib/main.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/lib/main.js b/lib/main.js new file mode 100644 index 00000000..2478b351 --- /dev/null +++ b/lib/main.js @@ -0,0 +1,9 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const constants_1 = require("./constants"); +const lib_1 = __importDefault(require("./lib")); +// Runs the action within the GitHub actions environment. +lib_1.default(constants_1.action); diff --git a/lib/util.d.ts b/lib/util.d.ts new file mode 100644 index 00000000..5e3bfa7b --- /dev/null +++ b/lib/util.d.ts @@ -0,0 +1,6 @@ +import { actionInterface } from "./constants"; +export declare const isNullOrUndefined: (value: any) => boolean; +export declare const generateTokenType: (action: actionInterface) => string; +export declare const generateRepositoryPath: (action: actionInterface) => string; +export declare const hasRequiredParameters: (action: actionInterface) => void; +export declare const suppressSensitiveInformation: (str: string, action: actionInterface) => string; diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..1f588d86 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@actions/core"); +/* Utility function that checks to see if a value is undefined or not. */ +exports.isNullOrUndefined = (value) => typeof value === "undefined" || value === null || value === ""; +/* Generates a token type used for the action. */ +exports.generateTokenType = (action) => action.ssh + ? "SSH Deploy Key" + : action.accessToken + ? "Access Token" + : action.gitHubToken + ? "GitHub Token" + : "..."; +/* Generates a the repository path used to make the commits. */ +exports.generateRepositoryPath = (action) => action.ssh + ? `git@github.com:${action.repositoryName}` + : `https://${action.accessToken || + `x-access-token:${action.gitHubToken}`}@github.com/${action.repositoryName}.git`; +/* Checks for the required tokens and formatting. Throws an error if any case is matched. */ +exports.hasRequiredParameters = (action) => { + if ((exports.isNullOrUndefined(action.accessToken) && + exports.isNullOrUndefined(action.gitHubToken) && + exports.isNullOrUndefined(action.ssh)) || + exports.isNullOrUndefined(action.repositoryPath)) { + throw new Error("No deployment token/method was provided. You must provide the action with either a Personal Access Token or the GitHub Token secret in order to deploy. If you wish to use an ssh deploy token then you must set SSH to true."); + } + if (exports.isNullOrUndefined(action.branch)) { + throw new Error("Branch is required."); + } + if (!action.folder || exports.isNullOrUndefined(action.folder)) { + throw new Error("You must provide the action with a folder to deploy."); + } + if (action.folder.startsWith("/") || action.folder.startsWith("./")) { + throw new Error("Incorrectly formatted build folder. The deployment folder cannot be prefixed with '/' or './'. Instead reference the folder name directly."); + } +}; +/* Suppresses sensitive information from being exposed in error messages. */ +exports.suppressSensitiveInformation = (str, action) => { + let value = str; + if (core_1.getInput("DEBUG")) { + // Data is unmasked in debug mode. + return value; + } + if (action.accessToken) { + value = value.replace(action.accessToken, "***"); + } + if (action.gitHubToken) { + value = value.replace(action.gitHubToken, "***"); + } + if (action.repositoryPath) { + value = value.replace(action.repositoryPath, "***"); + } + return value; +};