├── .nvmrc ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── assets │ ├── icon.png │ └── screenshot.png ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ └── BUG_REPORT.md └── workflows │ ├── deploy.yml │ ├── codeql-analysis.yml │ ├── sponsors.yml │ ├── production.yml │ ├── publish.yml │ ├── build.yml │ └── integration.yml ├── __tests__ ├── env.js ├── execute.test.ts ├── worktree.error.test.ts ├── main.test.ts ├── ssh.test.ts ├── worktree.test.ts ├── util.test.ts └── git.test.ts ├── integration ├── image.jpg └── index.html ├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── src ├── main.ts ├── execute.ts ├── lib.ts ├── worktree.ts ├── ssh.ts ├── util.ts ├── constants.ts └── git.ts ├── tsconfig.lint.json ├── .prettierrc.json ├── tsconfig.json ├── .gitignore ├── jest.config.js ├── SECURITY.md ├── LICENSE ├── package.json ├── .eslintrc.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── action.yml └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.18.1 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @JamesIves 2 | -------------------------------------------------------------------------------- /__tests__/env.js: -------------------------------------------------------------------------------- 1 | process.env.ACTIONS_STEP_DEBUG = 'false' 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [JamesIves] 4 | -------------------------------------------------------------------------------- /integration/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wulf/github-pages-deploy-action/dev/integration/image.jpg -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT=12 2 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:${VARIANT} -------------------------------------------------------------------------------- /.github/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wulf/github-pages-deploy-action/dev/.github/assets/icon.png -------------------------------------------------------------------------------- /.github/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wulf/github-pages-deploy-action/dev/.github/assets/screenshot.png -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {action} from './constants' 2 | import run from './lib' 3 | 4 | // Runs the action within the GitHub actions environment. 5 | run(action) 6 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "." 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '10:00' 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "esModuleInterop": true 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Testing Instructions 6 | 7 | 8 | 9 | ## Additional Notes 10 | 11 | 12 | -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT=12-buster 2 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:dev-${VARIANT} 3 | 4 | # Install tslint, typescript. eslint is installed by javascript image 5 | ARG NODE_MODULES="tslint-to-eslint-config typescript" 6 | RUN su node -c "umask 0002 && npm install -g ${NODE_MODULES}" \ 7 | && npm cache clean --force > /dev/null 2>&1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .DS_Store 2 | .DS_Store 3 | 4 | # Integration Tests 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | node_modules 9 | 10 | # Test Temp Files 11 | assets/.nojekyll 12 | assets/CNAME 13 | 14 | # Build 15 | lib 16 | 17 | ## Registry 18 | package-lock.json 19 | yarn-error.log 20 | 21 | ## SSH 22 | .ssh 23 | *.pub 24 | 25 | ## CodeCov 26 | coverage 27 | 28 | # Yarn Integrity file 29 | .yarn-integrity -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true, 11 | setupFiles: ['/__tests__/env.js'], 12 | collectCoverage: true, 13 | collectCoverageFrom: ['src/*.ts', '!src/constants.ts'] 14 | } 15 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // https://github.com/microsoft/vscode-dev-containers/tree/master/containers/typescript-node 2 | { 3 | "name": "Node.js & TypeScript", 4 | "build": { 5 | "dockerfile": "Dockerfile", 6 | "args": { 7 | "VARIANT": "12" 8 | } 9 | }, 10 | "settings": { 11 | "terminal.integrated.shell.linux": "/bin/bash" 12 | }, 13 | "extensions": ["dbaeumer.vscode-eslint"], 14 | "remoteUser": "node" 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request and Ideas 4 | url: https://github.com/JamesIves/github-pages-deploy-action/discussions?discussions_q=category%3AIdeas 5 | about: If you have an idea or would like to make a feature request please open a discussion thread. 6 | 7 | - name: Support and Questions 8 | url: https://github.com/JamesIves/github-pages-deploy-action/discussions?discussions_q=category%3AQ%26A 9 | about: Need help or just have a general question? Please open a discussion thread. 10 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The current version is actively maintained and will receive frequent updates and security patches. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 4.0.x | :white_check_mark: | 10 | | < 3.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please disclose any security vulnerabilities either through the issues interface (as a bug) or by [emailing the project maintainer](https://jamesiv.es). Please bare in mind that this project is voluntarily maintained and updates will be worked on based on availability. 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Code to Release Branch 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | name: Push to Release Branch 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | 13 | # Workflow dispatch event that pushes the current version to the release branch. 14 | # From here the secondary production deployment workflow will trigger to build the dependencies. 15 | - name: Deploy 🚀 16 | uses: JamesIves/github-pages-deploy-action@4.0.0 17 | with: 18 | branch: releases/v4 19 | folder: . 20 | clean: false 21 | single-commit: true 22 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | - 'dev-v*' 7 | - 'releases/v*' 8 | pull_request: 9 | branches: 10 | - dev 11 | - 'dev-v*' 12 | schedule: 13 | - cron: '0 9 * * 4' 14 | 15 | jobs: 16 | analyse: 17 | name: Analyse 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v2 23 | 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v1 26 | 27 | - name: Autobuild 28 | uses: github/codeql-action/autobuild@v1 29 | 30 | - name: Perform CodeQL Analysis 31 | uses: github/codeql-action/analyze@v1 32 | -------------------------------------------------------------------------------- /integration/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Integration Test 6 | 7 | 8 | 28 | 29 | 30 |
31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/sponsors.yml: -------------------------------------------------------------------------------- 1 | name: publish-sponsors 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 30 15 * * 0-6 6 | 7 | jobs: 8 | generate-sponsors: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v2 13 | 14 | - name: Generate Sponsors 💖 15 | uses: JamesIves/github-sponsors-readme-action@releases/v1 16 | with: 17 | token: ${{ secrets.PAT }} 18 | file: 'README.md' 19 | template: '' 20 | minimum: 500 21 | 22 | - name: Deploy to GitHub Pages 23 | uses: JamesIves/github-pages-deploy-action@4.1.4 24 | with: 25 | branch: dev 26 | folder: '.' 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to help us improve the action. 4 | labels: 5 | - triage ⚠️ 6 | --- 7 | 8 | 9 | 10 | ## Describe the bug 11 | 12 | 13 | 14 | ## Reproduction Steps 15 | 16 | 17 | 18 | ## Logs 19 | 20 | 21 | 22 | ## Additional Comments 23 | 24 | 25 | -------------------------------------------------------------------------------- /__tests__/execute.test.ts: -------------------------------------------------------------------------------- 1 | import {execute, stdout} from '../src/execute' 2 | import {exec} from '@actions/exec' 3 | 4 | jest.mock('@actions/exec', () => ({ 5 | exec: jest.fn() 6 | })) 7 | 8 | describe('execute', () => { 9 | it('should be called with the correct arguments when silent mode is enabled', async () => { 10 | stdout('hello') 11 | await execute('echo Montezuma', './', true) 12 | 13 | expect(exec).toBeCalledWith('echo Montezuma', [], { 14 | cwd: './', 15 | silent: true, 16 | listeners: { 17 | stdout: expect.any(Function) 18 | } 19 | }) 20 | }) 21 | 22 | it('should not silence the input when action.silent is false', async () => { 23 | process.env['RUNNER_DEBUG'] = '1' 24 | 25 | stdout('hello') 26 | await execute('echo Montezuma', './', false) 27 | 28 | expect(exec).toBeCalledWith('echo Montezuma', [], { 29 | cwd: './', 30 | silent: false, 31 | listeners: { 32 | stdout: expect.any(Function) 33 | } 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/execute.ts: -------------------------------------------------------------------------------- 1 | import {exec} from '@actions/exec' 2 | import buffer from 'buffer' 3 | 4 | let output = '' 5 | 6 | /** Wrapper around the GitHub toolkit exec command which returns the output. 7 | * Also allows you to easily toggle the current working directory. 8 | * 9 | * @param {string} cmd - The command to execute. 10 | * @param {string} cwd - The current working directory. 11 | * @param {boolean} silent - Determines if the in/out should be silenced or not. 12 | */ 13 | export async function execute( 14 | cmd: string, 15 | cwd: string, 16 | silent: boolean 17 | ): Promise { 18 | output = '' 19 | 20 | await exec(cmd, [], { 21 | // Silences the input unless the INPUT_DEBUG flag is set. 22 | silent, 23 | cwd, 24 | listeners: { 25 | stdout 26 | } 27 | }) 28 | 29 | return Promise.resolve(output) 30 | } 31 | 32 | export function stdout(data: Buffer | string): void { 33 | const dataString = data.toString().trim() 34 | if (output.length + dataString.length < buffer.constants.MAX_STRING_LENGTH) { 35 | output += dataString 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Ives 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__tests__/worktree.error.test.ts: -------------------------------------------------------------------------------- 1 | import {TestFlag} from '../src/constants' 2 | import {execute} from '../src/execute' 3 | import {generateWorktree} from '../src/worktree' 4 | 5 | jest.mock('../src/execute', () => ({ 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | __esModule: true, 8 | execute: jest.fn() 9 | })) 10 | 11 | describe('generateWorktree', () => { 12 | it('should catch when a function throws an error', async () => { 13 | ;(execute as jest.Mock).mockImplementationOnce(() => { 14 | throw new Error('Mocked throw') 15 | }) 16 | try { 17 | await generateWorktree( 18 | { 19 | hostname: 'github.com', 20 | workspace: 'somewhere', 21 | singleCommit: false, 22 | branch: 'gh-pages', 23 | folder: '', 24 | silent: true, 25 | isTest: TestFlag.HAS_CHANGED_FILES 26 | }, 27 | 'worktree', 28 | true 29 | ) 30 | } catch (error) { 31 | expect(error instanceof Error && error.message).toBe( 32 | 'There was an error creating the worktree: Mocked throw ❌' 33 | ) 34 | } 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /.github/workflows/production.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Production Dependencies and Code 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - 'releases/v*' 7 | tags-ignore: 8 | - '*.*' 9 | 10 | jobs: 11 | build: 12 | name: Build Production 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v1.4.4 19 | with: 20 | node-version: 'v14.18.1' 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - name: Install Yarn 24 | run: npm install -g yarn 25 | 26 | - name: Clobber lib 27 | run: rm -rf lib 28 | 29 | - name: Set up .gitignore 30 | run: | 31 | sed -i -e's/^lib/# lib/' -e's/^node_module/# node_modules/' .gitignore 32 | 33 | - name: Build 34 | run: | 35 | yarn install 36 | yarn build 37 | 38 | - name: Install Production node_modules 39 | run: | 40 | yarn install --production 41 | 42 | - name: Commit and Push 43 | # Keep the run green if the commit fails for the lack of changes 44 | continue-on-error: True 45 | run: | 46 | git config user.email "${{ secrets.GIT_CONFIG_EMAIL }}" 47 | git config user.name "${{ secrets.GIT_CONFIG_NAME }}" 48 | git add . 49 | git commit -m "Deploy Production Code for Commit ${{ github.sha }} 🚀" 50 | git push 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jamesives/github-pages-deploy-action", 3 | "description": "GitHub action for building a project and deploying it to GitHub pages.", 4 | "author": "James Ives (https://jamesiv.es)", 5 | "version": "4.1.6", 6 | "license": "MIT", 7 | "main": "lib/lib.js", 8 | "types": "lib/lib.d.ts", 9 | "scripts": { 10 | "build": "rimraf lib && tsc --declaration", 11 | "test": "jest", 12 | "lint": "eslint src/**/*.ts __tests__/**/*.ts", 13 | "lint:format": "prettier --write './**/*.{ts,js,json,yml,md}' './*.{ts,js,json,yml,md}'" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/JamesIves/github-pages-deploy-action.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/JamesIves/github-pages-deploy-action/issues" 21 | }, 22 | "homepage": "https://github.com/JamesIves/github-pages-deploy-action", 23 | "keywords": [ 24 | "actions", 25 | "node", 26 | "setup", 27 | "build", 28 | "deploy", 29 | "gh-pages", 30 | "pages", 31 | "github", 32 | "deploy", 33 | "deployment" 34 | ], 35 | "dependencies": { 36 | "@actions/core": "1.6.0", 37 | "@actions/exec": "1.1.0", 38 | "@actions/github": "5.0.0", 39 | "@actions/io": "1.1.1" 40 | }, 41 | "devDependencies": { 42 | "@types/jest": "27.0.2", 43 | "@types/node": "16.11.7", 44 | "@typescript-eslint/eslint-plugin": "4.33.0", 45 | "@typescript-eslint/parser": "4.33.0", 46 | "eslint": "7.32.0", 47 | "eslint-config-prettier": "8.3.0", 48 | "eslint-plugin-jest": "25.2.4", 49 | "eslint-plugin-prettier": "4.0.0", 50 | "jest": "26.6.3", 51 | "jest-circus": "27.3.1", 52 | "prettier": "2.4.1", 53 | "rimraf": "3.0.2", 54 | "ts-jest": "26.5.6", 55 | "typescript": "4.5.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish-to-npm 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'The updated registry version number.' 7 | required: true 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | ref: dev 16 | 17 | # Setup .npmrc file to publish to npm 18 | - uses: actions/setup-node@v1.4.4 19 | with: 20 | node-version: 'v14.18.1' 21 | registry-url: 'https://registry.npmjs.org' 22 | scope: '@jamesives' 23 | 24 | - name: Configure Git 25 | run: | 26 | git config user.email "iam@jamesiv.es" 27 | git config user.name "James Ives" 28 | 29 | - name: Install Yarn 30 | run: npm install -g yarn 31 | 32 | - run: yarn install --frozen-lockfile 33 | - run: yarn build 34 | - run: git stash 35 | - run: npm version ${{ github.event.inputs.version }} -m "Release ${{ github.event.inputs.version }} 📣" 36 | - run: git push 37 | 38 | # Publish to npm 39 | - run: npm publish --access public 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | 43 | # Setup .npmrc file to publish to GitHub Packages 44 | - uses: actions/setup-node@v1.4.4 45 | with: 46 | node-version: 'v14.18.1' 47 | registry-url: 'https://npm.pkg.github.com' 48 | scope: '@jamesives' 49 | 50 | - name: Authenticate with the GitHub Package Registry 51 | run: 52 | echo "//npm.pkg.github.com:_authToken=${{ secrets.GITHUB_TOKEN }}" > 53 | ~/.npmrc 54 | 55 | # Publish to GitHub Packages 56 | - run: npm publish 57 | env: 58 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 9, 11 | "sourceType": "module", 12 | "project": "./tsconfig.lint.json" 13 | }, 14 | "globals": { 15 | "fetch": true 16 | }, 17 | "env": { 18 | "node": true, 19 | "es6": true, 20 | "jest/globals": true 21 | }, 22 | "rules": { 23 | "@typescript-eslint/ban-types": [ 24 | "error", 25 | { 26 | "types": { 27 | "Number": { 28 | "message": "Use number instead", 29 | "fixWith": "number" 30 | }, 31 | "String": { 32 | "message": "Use string instead", 33 | "fixWith": "string" 34 | }, 35 | "Boolean": { 36 | "message": "Use boolean instead", 37 | "fixWith": "boolean" 38 | }, 39 | "Object": { 40 | "message": "Use object instead", 41 | "fixWith": "object" 42 | }, 43 | "{}": { 44 | "message": "Use object instead", 45 | "fixWith": "object" 46 | }, 47 | "Symbol": { 48 | "message": "Use symbol instead", 49 | "fixWith": "symbol" 50 | } 51 | } 52 | } 53 | ], 54 | "@typescript-eslint/array-type": ["error", {"default": "array"}], 55 | "@typescript-eslint/explicit-module-boundary-types": "error", 56 | "@typescript-eslint/no-explicit-any": "error", 57 | "@typescript-eslint/no-unused-vars": "error", 58 | "@typescript-eslint/explicit-function-return-type": "error", 59 | "object-shorthand": ["error", "always"], 60 | "prefer-destructuring": [ 61 | "error", 62 | { 63 | "array": false, 64 | "object": true 65 | }, 66 | { 67 | "enforceForRenamedProperties": false 68 | } 69 | ], 70 | "no-console": ["error", {"allow": ["warn", "error"]}], 71 | "no-alert": "error", 72 | "no-debugger": "error" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import {exportVariable, info, setFailed, setOutput} from '@actions/core' 2 | import {ActionInterface, NodeActionInterface, Status} from './constants' 3 | import {deploy, init} from './git' 4 | import {configureSSH} from './ssh' 5 | import { 6 | checkParameters, 7 | extractErrorMessage, 8 | generateFolderPath, 9 | generateRepositoryPath, 10 | generateTokenType 11 | } from './util' 12 | 13 | /** Initializes and runs the action. 14 | * 15 | * @param {object} configuration - The action configuration. 16 | */ 17 | export default async function run( 18 | configuration: ActionInterface | NodeActionInterface 19 | ): Promise { 20 | let status: Status = Status.RUNNING 21 | 22 | try { 23 | info(` 24 | GitHub Pages Deploy Action 🚀 25 | 26 | 🚀 Getting Started Guide: https://github.com/marketplace/actions/deploy-to-github-pages 27 | ❓ Discussions / Q&A: https://github.com/JamesIves/github-pages-deploy-action/discussions 28 | 🔧 Report a Bug: https://github.com/JamesIves/github-pages-deploy-action/issues 29 | 30 | 📣 Maintained by James Ives: https://jamesiv.es 31 | 💖 Support: https://github.com/sponsors/JamesIves`) 32 | 33 | info('Checking configuration and starting deployment… 🚦') 34 | 35 | const settings: ActionInterface = { 36 | ...configuration 37 | } 38 | 39 | // Defines the repository/folder paths and token types. 40 | // Also verifies that the action has all of the required parameters. 41 | settings.folderPath = generateFolderPath(settings) 42 | 43 | checkParameters(settings) 44 | 45 | settings.repositoryPath = generateRepositoryPath(settings) 46 | settings.tokenType = generateTokenType(settings) 47 | 48 | if (settings.sshKey) { 49 | await configureSSH(settings) 50 | } 51 | 52 | await init(settings) 53 | status = await deploy(settings) 54 | } catch (error) { 55 | status = Status.FAILED 56 | setFailed(extractErrorMessage(error)) 57 | } finally { 58 | info( 59 | `${ 60 | status === Status.FAILED 61 | ? 'Deployment failed! ❌' 62 | : status === Status.SUCCESS 63 | ? 'Completed deployment successfully! ✅' 64 | : 'There is nothing to commit. Exiting early… 📭' 65 | }` 66 | ) 67 | 68 | exportVariable('deployment_status', status) 69 | setOutput('deployment-status', status) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/worktree.ts: -------------------------------------------------------------------------------- 1 | import {info} from '@actions/core' 2 | import {ActionInterface} from './constants' 3 | import {execute} from './execute' 4 | import {extractErrorMessage, suppressSensitiveInformation} from './util' 5 | 6 | export class GitCheckout { 7 | orphan = false 8 | commitish?: string | null = null 9 | branch: string 10 | constructor(branch: string) { 11 | this.branch = branch 12 | } 13 | toString(): string { 14 | return [ 15 | 'git', 16 | 'checkout', 17 | this.orphan ? '--orphan' : '-B', 18 | this.branch, 19 | this.commitish || '' 20 | ].join(' ') 21 | } 22 | } 23 | 24 | /* Generate the worktree and set initial content if it exists */ 25 | export async function generateWorktree( 26 | action: ActionInterface, 27 | worktreedir: string, 28 | branchExists: unknown 29 | ): Promise { 30 | try { 31 | info('Creating worktree…') 32 | 33 | if (branchExists) { 34 | await execute( 35 | `git fetch --no-recurse-submodules --depth=1 origin ${action.branch}`, 36 | action.workspace, 37 | action.silent 38 | ) 39 | } 40 | 41 | await execute( 42 | `git worktree add --no-checkout --detach ${worktreedir}`, 43 | action.workspace, 44 | action.silent 45 | ) 46 | const checkout = new GitCheckout(action.branch) 47 | if (branchExists) { 48 | // There's existing data on the branch to check out 49 | checkout.commitish = `origin/${action.branch}` 50 | } 51 | if (!branchExists || action.singleCommit) { 52 | // Create a new history if we don't have the branch, or if we want to reset it 53 | checkout.orphan = true 54 | } 55 | await execute( 56 | checkout.toString(), 57 | `${action.workspace}/${worktreedir}`, 58 | action.silent 59 | ) 60 | if (!branchExists) { 61 | info(`Created the ${action.branch} branch… 🔧`) 62 | // Our index is in HEAD state, reset 63 | await execute( 64 | 'git reset --hard', 65 | `${action.workspace}/${worktreedir}`, 66 | action.silent 67 | ) 68 | if (!action.singleCommit) { 69 | // New history isn't singleCommit, create empty initial commit 70 | await execute( 71 | `git commit --no-verify --allow-empty -m "Initial ${action.branch} commit"`, 72 | `${action.workspace}/${worktreedir}`, 73 | action.silent 74 | ) 75 | } 76 | } 77 | } catch (error) { 78 | throw new Error( 79 | `There was an error creating the worktree: ${suppressSensitiveInformation( 80 | extractErrorMessage(error), 81 | action 82 | )} ❌` 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing ✏️ 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | [email, or any other method with the owners of this repository](https://jamesiv.es) before making a change. If you are planning to work on an issue that already exists please let us know before writing any code incase it's already in flight! 5 | 6 | ## Before Making a Pull Request 🎒 7 | 8 | 1. Ensure that you've tested your feature/change yourself. As the primary focus of this project is deployment, providing a link to a deployed repository using your branch is preferred. You can reference the forked action using your GitHub username, for example `yourname/github-pages-deplpy-action@dev`. 9 | 2. Ensure your change passes all of the integration tests. 10 | 3. Make sure you update the README if you've made a change that requires documentation. 11 | 4. When making a pull request, highlight any areas that may cause a breaking change so the maintainer can update the version number accordingly on the GitHub marketplace and package registries. 12 | 5. Make sure you've formatted and linted your code. You can do this by running `yarn format` and `yarn lint`. 13 | 6. Fix or add any tests where applicable. You can run `yarn test` to run the suite. As this action is small in scope it's important that a high level of test coverage is maintained. All tests are written using [Jest](https://jestjs.io/). 14 | 7. As this package is written in [TypeScript](https://www.typescriptlang.org/) please ensure all typing is accurate and the action compiles correctly by running `yarn build`. 15 | 16 | ## Deploying 🚚 17 | 18 | In order to deploy and test your own fork of this action, you must commit the `node_modules` dependencies. Be sure to run `nvm use` before installing any dependencies. You can learn more about nvm [here](https://github.com/nvm-sh/nvm/blob/master/README.md). 19 | 20 | To do this you can follow the instructions below: 21 | 22 | Install the project: 23 | 24 | ``` 25 | yarn install 26 | ``` 27 | 28 | Comment out the following in distribution branches: 29 | 30 | ``` 31 | # node_modules/ 32 | # lib/ 33 | ``` 34 | 35 | Build the project: 36 | 37 | ``` 38 | yarn build 39 | ``` 40 | 41 | Commit: 42 | 43 | ``` 44 | $ git checkout -b branchnamehere 45 | $ git commit -a -m "prod dependencies" 46 | ``` 47 | 48 | The `node_modules` and `lib` folders should _not_ be included when making a pull request. These are only required for GitHub Actions when it consumes the distribution branch, the `dev` branch of the project should be free from any dependencies or lib files. 49 | 50 | ## Resources 💡 51 | 52 | - [TypeScript](https://www.typescriptlang.org/) 53 | - [Jest](https://jestjs.io/) 54 | - [GitHub Actions Documentation](https://help.github.com/en/actions) 55 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | // Initial env variable setup for tests. 2 | process.env['INPUT_FOLDER'] = 'build' 3 | process.env['GITHUB_SHA'] = '123' 4 | process.env['INPUT_DEBUG'] = 'debug' 5 | 6 | import '../src/main' 7 | import {action, TestFlag} from '../src/constants' 8 | import run from '../src/lib' 9 | import {execute} from '../src/execute' 10 | import {rmRF} from '@actions/io' 11 | import {setFailed, exportVariable} from '@actions/core' 12 | 13 | const originalAction = JSON.stringify(action) 14 | 15 | jest.mock('../src/execute', () => ({ 16 | execute: jest.fn() 17 | })) 18 | 19 | jest.mock('@actions/io', () => ({ 20 | rmRF: jest.fn() 21 | })) 22 | 23 | jest.mock('@actions/core', () => ({ 24 | setFailed: jest.fn(), 25 | getInput: jest.fn(), 26 | setOutput: jest.fn(), 27 | exportVariable: jest.fn(), 28 | isDebug: jest.fn(), 29 | info: jest.fn() 30 | })) 31 | 32 | describe('main', () => { 33 | afterEach(() => { 34 | Object.assign(action, JSON.parse(originalAction)) 35 | }) 36 | 37 | it('should run through the commands', async () => { 38 | Object.assign(action, { 39 | repositoryPath: 'JamesIves/github-pages-deploy-action', 40 | folder: '.github/assets', 41 | branch: 'branch', 42 | token: '123', 43 | hostname: 'github.com', 44 | pusher: { 45 | name: 'asd', 46 | email: 'as@cat' 47 | }, 48 | isTest: TestFlag.NONE, 49 | debug: true 50 | }) 51 | await run(action) 52 | expect(execute).toBeCalledTimes(15) 53 | expect(rmRF).toBeCalledTimes(1) 54 | expect(exportVariable).toBeCalledTimes(1) 55 | }) 56 | 57 | it('should run through the commands and succeed', async () => { 58 | Object.assign(action, { 59 | hostname: 'github.com', 60 | repositoryPath: 'JamesIves/github-pages-deploy-action', 61 | folder: '.github/assets', 62 | branch: 'branch', 63 | token: '123', 64 | sshKey: true, 65 | pusher: { 66 | name: 'asd', 67 | email: 'as@cat' 68 | }, 69 | isTest: TestFlag.HAS_CHANGED_FILES 70 | }) 71 | await run(action) 72 | expect(execute).toBeCalledTimes(18) 73 | expect(rmRF).toBeCalledTimes(1) 74 | expect(exportVariable).toBeCalledTimes(1) 75 | }) 76 | 77 | it('should throw if an error is encountered', async () => { 78 | Object.assign(action, { 79 | hostname: 'github.com', 80 | folder: '.github/assets', 81 | branch: 'branch', 82 | token: null, 83 | sshKey: null, 84 | pusher: { 85 | name: 'asd', 86 | email: 'as@cat' 87 | }, 88 | isTest: TestFlag.HAS_CHANGED_FILES 89 | }) 90 | await run(action) 91 | expect(execute).toBeCalledTimes(0) 92 | expect(setFailed).toBeCalledTimes(1) 93 | expect(exportVariable).toBeCalledTimes(1) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/ssh.ts: -------------------------------------------------------------------------------- 1 | import {exportVariable, info} from '@actions/core' 2 | import {mkdirP} from '@actions/io' 3 | import {execFileSync, execSync} from 'child_process' 4 | import {appendFileSync} from 'fs' 5 | import {ActionInterface} from './constants' 6 | import {extractErrorMessage, suppressSensitiveInformation} from './util' 7 | 8 | export async function configureSSH(action: ActionInterface): Promise { 9 | try { 10 | if (typeof action.sshKey === 'string') { 11 | const sshDirectory = `${process.env['HOME']}/.ssh` 12 | const sshKnownHostsDirectory = `${sshDirectory}/known_hosts` 13 | 14 | // SSH fingerprints provided by GitHub: https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/githubs-ssh-key-fingerprints 15 | const sshGitHubKnownHostRsa = `\n${action.hostname} ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n` 16 | const sshGitHubKnownHostDss = `\n${action.hostname} ssh-dss AAAAB3NzaC1kc3MAAACBANGFW2P9xlGU3zWrymJgI/lKo//ZW2WfVtmbsUZJ5uyKArtlQOT2+WRhcg4979aFxgKdcsqAYW3/LS1T2km3jYW/vr4Uzn+dXWODVk5VlUiZ1HFOHf6s6ITcZvjvdbp6ZbpM+DuJT7Bw+h5Fx8Qt8I16oCZYmAPJRtu46o9C2zk1AAAAFQC4gdFGcSbp5Gr0Wd5Ay/jtcldMewAAAIATTgn4sY4Nem/FQE+XJlyUQptPWMem5fwOcWtSXiTKaaN0lkk2p2snz+EJvAGXGq9dTSWHyLJSM2W6ZdQDqWJ1k+cL8CARAqL+UMwF84CR0m3hj+wtVGD/J4G5kW2DBAf4/bqzP4469lT+dF2FRQ2L9JKXrCWcnhMtJUvua8dvnwAAAIB6C4nQfAA7x8oLta6tT+oCk2WQcydNsyugE8vLrHlogoWEicla6cWPk7oXSspbzUcfkjN3Qa6e74PhRkc7JdSdAlFzU3m7LMkXo1MHgkqNX8glxWNVqBSc0YRdbFdTkL0C6gtpklilhvuHQCdbgB3LBAikcRkDp+FCVkUgPC/7Rw==\n` 17 | 18 | info(`Configuring SSH client… 🔑`) 19 | 20 | await mkdirP(sshDirectory) 21 | 22 | appendFileSync(sshKnownHostsDirectory, sshGitHubKnownHostRsa) 23 | appendFileSync(sshKnownHostsDirectory, sshGitHubKnownHostDss) 24 | 25 | // Initializes SSH agent. 26 | const agentOutput = execFileSync('ssh-agent').toString().split('\n') 27 | 28 | agentOutput.map(line => { 29 | const exportableVariables = 30 | /^(SSH_AUTH_SOCK|SSH_AGENT_PID)=(.*); export \1/.exec(line) 31 | 32 | if (exportableVariables && exportableVariables.length) { 33 | exportVariable(exportableVariables[1], exportableVariables[2]) 34 | } 35 | }) 36 | 37 | // Adds the SSH key to the agent. 38 | action.sshKey.split(/(?=-----BEGIN)/).map(async line => { 39 | execSync('ssh-add -', {input: `${line.trim()}\n`}) 40 | }) 41 | 42 | execSync('ssh-add -l') 43 | } else { 44 | info(`Skipping SSH client configuration… ⌚`) 45 | } 46 | } catch (error) { 47 | throw new Error( 48 | `The ssh client configuration encountered an error: ${suppressSensitiveInformation( 49 | extractErrorMessage(error), 50 | action 51 | )} ❌` 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at iam@jamesiv.es. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: unit-tests 2 | on: 3 | pull_request: 4 | branches: 5 | - 'dev*' 6 | - 'releases/v*' 7 | push: 8 | branches: 9 | - 'dev*' 10 | tags-ignore: 11 | - '*.*' 12 | jobs: 13 | unit-tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - uses: actions/setup-node@v1.4.4 20 | with: 21 | node-version: 'v14.18.1' 22 | registry-url: 'https://registry.npmjs.org' 23 | 24 | - name: Install Yarn 25 | run: npm install -g yarn 26 | 27 | - name: Install and Test 28 | run: | 29 | yarn install 30 | yarn lint 31 | yarn test 32 | 33 | - name: Uploade CodeCov Report 34 | uses: codecov/codecov-action@v1 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | 38 | build: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v2 43 | 44 | - uses: actions/setup-node@v1.4.4 45 | with: 46 | node-version: 'v14.18.1' 47 | registry-url: 'https://registry.npmjs.org' 48 | 49 | - name: Install Yarn 50 | run: npm install -g yarn 51 | 52 | - name: Build lib 53 | run: | 54 | yarn install 55 | yarn build 56 | 57 | - name: Rebuild production node_modules 58 | run: | 59 | yarn install --production 60 | ls node_modules 61 | 62 | - name: artifact 63 | uses: actions/upload-artifact@v2 64 | with: 65 | name: dist 66 | path: | 67 | lib 68 | node_modules 69 | 70 | integration: 71 | runs-on: ubuntu-latest 72 | needs: build 73 | strategy: 74 | matrix: 75 | branch: ['gh-pages', 'no-pages'] 76 | commit: ['singleCommit', 'add commits'] 77 | max-parallel: 1 78 | steps: 79 | - name: Checkout 80 | uses: actions/checkout@v2 81 | with: 82 | persist-credentials: false 83 | 84 | - uses: actions/setup-node@v1.4.4 85 | with: 86 | node-version: 'v14.18.1' 87 | registry-url: 'https://registry.npmjs.org' 88 | 89 | - name: Download artifact 90 | uses: actions/download-artifact@v2 91 | with: 92 | name: dist 93 | 94 | - name: Deploy 95 | id: unmodified 96 | uses: ./ 97 | with: 98 | folder: integration 99 | branch: ${{ matrix.branch }} 100 | single-commit: ${{ matrix.commit == 'singleCommit' }} 101 | dry-run: true 102 | 103 | # Usually, this should be skipped, but if the upstream gh-pages 104 | # branch doesn't match ours, it should still be a success. 105 | - name: Check step output 106 | run: | 107 | [[ \ 108 | ${{steps.unmodified.outputs.deployment-status}} = skipped || \ 109 | ${{steps.unmodified.outputs.deployment-status}} = success \ 110 | ]] 111 | 112 | - name: Tweak content to publish to existing branch 113 | if: ${{ matrix.branch == 'gh-pages' }} 114 | run: | 115 | echo "" >> integration/index.html 116 | 117 | - name: Deploy with modifications to existing branch 118 | id: modified 119 | uses: ./ 120 | if: ${{ matrix.branch == 'gh-pages' }} 121 | with: 122 | folder: integration 123 | branch: ${{ matrix.branch }} 124 | single-commit: ${{ matrix.commit == 'singleCommit' }} 125 | dry-run: true 126 | 127 | # The modified deployment should be a success, and not skipped. 128 | - name: Check step output 129 | if: ${{ matrix.branch == 'gh-pages' }} 130 | run: | 131 | [[ \ 132 | ${{steps.modified.outputs.deployment-status}} = success \ 133 | ]] 134 | -------------------------------------------------------------------------------- /__tests__/ssh.test.ts: -------------------------------------------------------------------------------- 1 | import {exportVariable} from '@actions/core' 2 | import {mkdirP} from '@actions/io' 3 | import child_process, {execFileSync, execSync} from 'child_process' 4 | import {appendFileSync} from 'fs' 5 | import {action, TestFlag} from '../src/constants' 6 | import {execute} from '../src/execute' 7 | import {configureSSH} from '../src/ssh' 8 | 9 | const originalAction = JSON.stringify(action) 10 | 11 | jest.mock('fs', () => ({ 12 | appendFileSync: jest.fn(), 13 | existsSync: jest.fn() 14 | })) 15 | 16 | jest.mock('child_process', () => ({ 17 | execFileSync: jest.fn(), 18 | execSync: jest.fn() 19 | })) 20 | 21 | jest.mock('@actions/io', () => ({ 22 | rmRF: jest.fn(), 23 | mkdirP: jest.fn() 24 | })) 25 | 26 | jest.mock('@actions/core', () => ({ 27 | setFailed: jest.fn(), 28 | getInput: jest.fn(), 29 | setOutput: jest.fn(), 30 | isDebug: jest.fn(), 31 | info: jest.fn(), 32 | exportVariable: jest.fn() 33 | })) 34 | 35 | jest.mock('../src/execute', () => ({ 36 | execute: jest.fn() 37 | })) 38 | 39 | describe('configureSSH', () => { 40 | afterEach(() => { 41 | Object.assign(action, JSON.parse(originalAction)) 42 | }) 43 | 44 | it('should skip client configuration if sshKey is set to true', async () => { 45 | Object.assign(action, { 46 | hostname: 'github.com', 47 | silent: false, 48 | folder: 'assets', 49 | branch: 'branch', 50 | sshKey: true, 51 | pusher: { 52 | name: 'asd', 53 | email: 'as@cat' 54 | }, 55 | isTest: TestFlag.HAS_CHANGED_FILES 56 | }) 57 | 58 | await configureSSH(action) 59 | 60 | expect(execute).toBeCalledTimes(0) 61 | expect(mkdirP).toBeCalledTimes(0) 62 | expect(appendFileSync).toBeCalledTimes(0) 63 | }) 64 | 65 | it('should configure the ssh client if a key is defined', async () => { 66 | ;(child_process.execFileSync as jest.Mock).mockImplementationOnce(() => { 67 | return 'SSH_AUTH_SOCK=/some/random/folder/agent.123; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=123; export SSH_AGENT_PID;' 68 | }) 69 | 70 | Object.assign(action, { 71 | hostname: 'github.com', 72 | silent: false, 73 | folder: 'assets', 74 | branch: 'branch', 75 | sshKey: '?=-----BEGIN 123 456\n 789', 76 | pusher: { 77 | name: 'asd', 78 | email: 'as@cat' 79 | }, 80 | isTest: TestFlag.HAS_CHANGED_FILES 81 | }) 82 | 83 | await configureSSH(action) 84 | 85 | expect(execFileSync).toBeCalledTimes(1) 86 | expect(exportVariable).toBeCalledTimes(2) 87 | expect(execSync).toBeCalledTimes(3) 88 | }) 89 | 90 | it('should not export variables if the return from ssh-agent is skewed', async () => { 91 | ;(child_process.execFileSync as jest.Mock).mockImplementationOnce(() => { 92 | return 'useless nonsense here;' 93 | }) 94 | 95 | Object.assign(action, { 96 | hostname: 'github.com', 97 | silent: false, 98 | folder: 'assets', 99 | branch: 'branch', 100 | sshKey: '?=-----BEGIN 123 456\n 789', 101 | pusher: { 102 | name: 'asd', 103 | email: 'as@cat' 104 | }, 105 | isTest: TestFlag.HAS_CHANGED_FILES 106 | }) 107 | 108 | await configureSSH(action) 109 | 110 | expect(execFileSync).toBeCalledTimes(1) 111 | expect(exportVariable).toBeCalledTimes(0) 112 | expect(execSync).toBeCalledTimes(3) 113 | }) 114 | 115 | it('should throw if something errors', async () => { 116 | ;(child_process.execFileSync as jest.Mock).mockImplementationOnce(() => { 117 | throw new Error('Mocked throw') 118 | }) 119 | 120 | Object.assign(action, { 121 | hostname: 'github.com', 122 | silent: false, 123 | folder: 'assets', 124 | branch: 'branch', 125 | sshKey: 'real_key', 126 | pusher: { 127 | name: 'asd', 128 | email: 'as@cat' 129 | }, 130 | isTest: TestFlag.HAS_CHANGED_FILES 131 | }) 132 | 133 | try { 134 | await configureSSH(action) 135 | } catch (error) { 136 | expect(error instanceof Error && error.message).toBe( 137 | 'The ssh client configuration encountered an error: Mocked throw ❌' 138 | ) 139 | } 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import {isDebug} from '@actions/core' 2 | import {existsSync} from 'fs' 3 | import path from 'path' 4 | import {ActionInterface, RequiredActionParameters} from './constants' 5 | 6 | /* Replaces all instances of a match in a string. */ 7 | const replaceAll = (input: string, find: string, replace: string): string => 8 | input.split(find).join(replace) 9 | 10 | /* Utility function that checks to see if a value is undefined or not. 11 | If allowEmptyString is passed the parameter is allowed to contain an empty string as a valid parameter. */ 12 | export const isNullOrUndefined = ( 13 | value: unknown, 14 | allowEmptyString = false 15 | ): boolean => 16 | allowEmptyString 17 | ? typeof value === 'undefined' || value === null 18 | : typeof value === 'undefined' || value === null || value === '' 19 | 20 | /* Generates a token type used for the action. */ 21 | export const generateTokenType = (action: ActionInterface): string => 22 | action.sshKey ? 'SSH Deploy Key' : action.token ? 'Deploy Token' : '…' 23 | 24 | /* Generates a the repository path used to make the commits. */ 25 | export const generateRepositoryPath = (action: ActionInterface): string => 26 | action.sshKey 27 | ? `git@${action.hostname}:${action.repositoryName}` 28 | : `https://${`x-access-token:${action.token}`}@${action.hostname}/${ 29 | action.repositoryName 30 | }.git` 31 | 32 | /* Genetate absolute folder path by the provided folder name */ 33 | export const generateFolderPath = (action: ActionInterface): string => { 34 | const folderName = action['folder'] 35 | return path.isAbsolute(folderName) 36 | ? folderName 37 | : folderName.startsWith('~') 38 | ? folderName.replace('~', process.env.HOME as string) 39 | : path.join(action.workspace, folderName) 40 | } 41 | 42 | /* Checks for the required tokens and formatting. Throws an error if any case is matched. */ 43 | const hasRequiredParameters = ( 44 | action: ActionInterface, 45 | params: K[] 46 | ): boolean => { 47 | const nonNullParams = params.filter( 48 | param => !isNullOrUndefined(action[param]) 49 | ) 50 | return Boolean(nonNullParams.length) 51 | } 52 | 53 | /* Verifies the action has the required parameters to run, otherwise throw an error. */ 54 | export const checkParameters = (action: ActionInterface): void => { 55 | if (!hasRequiredParameters(action, ['token', 'sshKey'])) { 56 | throw new Error( 57 | '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.' 58 | ) 59 | } 60 | 61 | if (!hasRequiredParameters(action, ['branch'])) { 62 | throw new Error('Branch is required.') 63 | } 64 | 65 | if (!hasRequiredParameters(action, ['folder'])) { 66 | throw new Error('You must provide the action with a folder to deploy.') 67 | } 68 | 69 | if (!existsSync(action.folderPath as string)) { 70 | throw new Error( 71 | `The directory you're trying to deploy named ${action.folderPath} doesn't exist. Please double check the path and any prerequisite build scripts and try again. ❗` 72 | ) 73 | } 74 | } 75 | 76 | /* Suppresses sensitive information from being exposed in error messages. */ 77 | export const suppressSensitiveInformation = ( 78 | str: string, 79 | action: ActionInterface 80 | ): string => { 81 | let value = str 82 | 83 | if (isDebug()) { 84 | // Data is unmasked in debug mode. 85 | return value 86 | } 87 | 88 | const orderedByLength = ( 89 | [action.token, action.repositoryPath].filter(Boolean) as string[] 90 | ).sort((a, b) => b.length - a.length) 91 | 92 | for (const find of orderedByLength) { 93 | value = replaceAll(value, find, '***') 94 | } 95 | 96 | return value 97 | } 98 | 99 | export const extractErrorMessage = (error: unknown): string => 100 | error instanceof Error 101 | ? error.message 102 | : typeof error == 'string' 103 | ? error 104 | : JSON.stringify(error) 105 | 106 | /** Strips the protocol from a provided URL. */ 107 | export const stripProtocolFromUrl = (url: string): string => 108 | url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '').split('/')[0] 109 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Deploy to GitHub Pages' 2 | description: 'This action will handle the deployment process of your project to GitHub Pages.' 3 | author: 'James Ives ' 4 | runs: 5 | using: 'node12' 6 | main: 'lib/main.js' 7 | branding: 8 | icon: 'git-commit' 9 | color: 'orange' 10 | inputs: 11 | ssh-key: 12 | description: > 13 | This option allows you to define a private SSH key to be used in conjunction with a repository deployment key to deploy using SSH. 14 | The private key should be stored in the `secrets / with` menu **as a secret**. The public should be stored in the repositories deployment 15 | keys menu and be given write access. 16 | 17 | Alternatively you can set this field to `true` to enable SSH endpoints for deployment without configuring the ssh client. This can be useful if you've 18 | already setup the SSH client using another package or action in a previous step. 19 | required: false 20 | 21 | token: 22 | description: > 23 | This option defaults to the repository scoped GitHub Token. 24 | However if you need more permissions for things such as deploying to another repository, you can add a Personal Access Token (PAT) here. 25 | This should be stored in the `secrets / with` menu **as a secret**. 26 | 27 | We recommend using a service account with the least permissions neccersary 28 | and when generating a new PAT that you select the least permission scopes required. 29 | 30 | [Learn more about creating and using encrypted secrets here.](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) 31 | required: false 32 | default: ${{ github.token }} 33 | 34 | branch: 35 | description: 'This is the branch you wish to deploy to, for example gh-pages or docs.' 36 | required: true 37 | 38 | folder: 39 | description: 'The folder in your repository that you want to deploy. If your build script compiles into a directory named build you would put it here. Folder paths cannot have a leading / or ./. If you wish to deploy the root directory you can place a . here.' 40 | required: true 41 | 42 | target-folder: 43 | description: 'If you would like to push the contents of the deployment folder into a specific directory on the deployment branch you can specify it here.' 44 | required: false 45 | 46 | commit-message: 47 | description: 'If you need to customize the commit message for an integration you can do so.' 48 | required: false 49 | 50 | clean: 51 | description: 'If your project generates hashed files on build you can use this option to automatically delete them from the target folder on the deployment branch with each deploy. This option is on by default and can be toggled off by setting it to false.' 52 | required: false 53 | default: true 54 | 55 | clean-exclude: 56 | description: 'If you need to use clean but you would like to preserve certain files or folders you can use this option. This should contain each pattern as a single line in a multiline string.' 57 | required: false 58 | 59 | dry-run: 60 | description: 'Do not actually push back, but use `--dry-run` on `git push` invocations insead.' 61 | required: false 62 | 63 | git-config-name: 64 | description: 'Allows you to customize the name that is attached to the GitHub config which is used when pushing the deployment commits. If this is not included it will use the name in the GitHub context, followed by the name of the action.' 65 | required: false 66 | 67 | git-config-email: 68 | description: 'Allows you to customize the email that is attached to the GitHub config which is used when pushing the deployment commits. If this is not included it will use the email in the GitHub context, followed by a generic noreply GitHub email.' 69 | required: false 70 | 71 | repository-name: 72 | description: 'Allows you to specify a different repository path so long as you have permissions to push to it. This should be formatted like so: JamesIves/github-pages-deploy-action' 73 | required: false 74 | 75 | workspace: 76 | description: "This should point to where your project lives on the virtual machine. The GitHub Actions environment will set this for you. It is only neccersary to set this variable if you're using the node module." 77 | required: false 78 | 79 | single-commit: 80 | description: "This option can be used if you'd prefer to have a single commit on the deployment branch instead of maintaining the full history." 81 | required: false 82 | 83 | silent: 84 | description: 'Silences the action output preventing it from displaying git messages.' 85 | required: false 86 | 87 | outputs: 88 | deployment-status: 89 | description: 'The status of the deployment that indicates if the run failed or passed. Possible outputs include: success|failed|skipped' 90 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import {getInput} from '@actions/core' 2 | import * as github from '@actions/github' 3 | import {isNullOrUndefined, stripProtocolFromUrl} from './util' 4 | 5 | const {pusher, repository} = github.context.payload 6 | 7 | /* Flags to signal different scenarios to test cases */ 8 | export enum TestFlag { 9 | NONE = 0, 10 | HAS_CHANGED_FILES = 1 << 1, // Assume changes to commit. 11 | HAS_REMOTE_BRANCH = 1 << 2, // Assume remote repository has existing commits. 12 | UNABLE_TO_REMOVE_ORIGIN = 1 << 3, // Assume we can't remove origin. 13 | UNABLE_TO_UNSET_GIT_CONFIG = 1 << 4 // Assume we can't remove previously set git configs. 14 | } 15 | 16 | /* For more information please refer to the README: https://github.com/JamesIves/github-pages-deploy-action */ 17 | export interface ActionInterface { 18 | /** The branch that the action should deploy to. */ 19 | branch: string 20 | /** git push with --dry-run */ 21 | dryRun?: boolean | null 22 | /** 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. */ 23 | clean?: boolean | null 24 | /** If you need to use CLEAN but you'd like to preserve certain files or folders you can use this option. */ 25 | cleanExclude?: string[] 26 | /** If you need to customize the commit message for an integration you can do so. */ 27 | commitMessage?: string 28 | /** The hostname of which the GitHub Workflow is being run on, ie: github.com */ 29 | hostname?: string 30 | /** The git config email. */ 31 | email?: string 32 | /** The folder to deploy. */ 33 | folder: string 34 | /** The auto generated folder path. */ 35 | folderPath?: string 36 | /** Determines test scenarios the action is running in. */ 37 | isTest: TestFlag 38 | /** The git config name. */ 39 | name?: string 40 | /** The repository path, for example JamesIves/github-pages-deploy-action. */ 41 | repositoryName?: string 42 | /** The fully qualified repositpory path, this gets auto generated if repositoryName is provided. */ 43 | repositoryPath?: string 44 | /** Wipes the commit history from the deployment branch in favor of a single commit. */ 45 | singleCommit?: boolean | null 46 | /** Determines if the action should run in silent mode or not. */ 47 | silent: boolean 48 | /** Defines an SSH private key that can be used during deployment. This can also be set to true to use SSH deployment endpoints if you've already configured the SSH client outside of this package. */ 49 | sshKey?: string | boolean | null 50 | /** 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. */ 51 | targetFolder?: string 52 | /** Deployment token. */ 53 | token?: string | null 54 | /** The token type, ie ssh/token, this gets automatically generated. */ 55 | tokenType?: string 56 | /** The folder where your deployment project lives. */ 57 | workspace: string 58 | } 59 | 60 | /** The minimum required values to run the action as a node module. */ 61 | export interface NodeActionInterface { 62 | /** The branch that the action should deploy to. */ 63 | branch: string 64 | /** The folder to deploy. */ 65 | folder: string 66 | /** The repository path, for example JamesIves/github-pages-deploy-action. */ 67 | repositoryName: string 68 | /** GitHub deployment token. */ 69 | token?: string | null 70 | /** Determines if the action should run in silent mode or not. */ 71 | silent: boolean 72 | /** Defines an SSH private key that can be used during deployment. This can also be set to true to use SSH deployment endpoints if you've already configured the SSH client outside of this package. */ 73 | sshKey?: string | boolean | null 74 | /** The folder where your deployment project lives. */ 75 | workspace: string 76 | /** Determines test scenarios the action is running in. */ 77 | isTest: TestFlag 78 | } 79 | 80 | /* Required action data that gets initialized when running within the GitHub Actions environment. */ 81 | export const action: ActionInterface = { 82 | folder: getInput('folder'), 83 | branch: getInput('branch'), 84 | commitMessage: getInput('commit-message'), 85 | dryRun: !isNullOrUndefined(getInput('dry-run')) 86 | ? getInput('dry-run').toLowerCase() === 'true' 87 | : false, 88 | clean: !isNullOrUndefined(getInput('clean')) 89 | ? getInput('clean').toLowerCase() === 'true' 90 | : false, 91 | cleanExclude: (getInput('clean-exclude') || '') 92 | .split('\n') 93 | .filter(l => l !== ''), 94 | hostname: process.env.GITHUB_SERVER_URL 95 | ? stripProtocolFromUrl(process.env.GITHUB_SERVER_URL) 96 | : 'github.com', 97 | isTest: TestFlag.NONE, 98 | email: !isNullOrUndefined(getInput('git-config-email'), true) 99 | ? getInput('git-config-email') 100 | : pusher && pusher.email 101 | ? pusher.email 102 | : `${ 103 | process.env.GITHUB_ACTOR || 'github-pages-deploy-action' 104 | }@users.noreply.${ 105 | process.env.GITHUB_SERVER_URL 106 | ? stripProtocolFromUrl(process.env.GITHUB_SERVER_URL) 107 | : 'github.com' 108 | }`, 109 | name: !isNullOrUndefined(getInput('git-config-name')) 110 | ? getInput('git-config-name') 111 | : pusher && pusher.name 112 | ? pusher.name 113 | : process.env.GITHUB_ACTOR 114 | ? process.env.GITHUB_ACTOR 115 | : 'GitHub Pages Deploy Action', 116 | repositoryName: !isNullOrUndefined(getInput('repository-name')) 117 | ? getInput('repository-name') 118 | : repository && repository.full_name 119 | ? repository.full_name 120 | : process.env.GITHUB_REPOSITORY, 121 | token: getInput('token'), 122 | singleCommit: !isNullOrUndefined(getInput('single-commit')) 123 | ? getInput('single-commit').toLowerCase() === 'true' 124 | : false, 125 | silent: !isNullOrUndefined(getInput('silent')) 126 | ? getInput('silent').toLowerCase() === 'true' 127 | : false, 128 | sshKey: isNullOrUndefined(getInput('ssh-key')) 129 | ? false 130 | : !isNullOrUndefined(getInput('ssh-key')) && 131 | getInput('ssh-key').toLowerCase() === 'true' 132 | ? true 133 | : getInput('ssh-key'), 134 | targetFolder: getInput('target-folder'), 135 | workspace: process.env.GITHUB_WORKSPACE || '' 136 | } 137 | 138 | /** Types for the required action parameters. */ 139 | export type RequiredActionParameters = Pick< 140 | ActionInterface, 141 | 'token' | 'sshKey' | 'branch' | 'folder' | 'isTest' 142 | > 143 | 144 | /** Status codes for the action. */ 145 | export enum Status { 146 | SUCCESS = 'success', 147 | FAILED = 'failed', 148 | SKIPPED = 'skipped', 149 | RUNNING = 'running' 150 | } 151 | -------------------------------------------------------------------------------- /__tests__/worktree.test.ts: -------------------------------------------------------------------------------- 1 | import {rmRF} from '@actions/io' 2 | import {TestFlag} from '../src/constants' 3 | import {generateWorktree} from '../src/worktree' 4 | import {execute} from '../src/execute' 5 | import fs from 'fs' 6 | import os from 'os' 7 | import path from 'path' 8 | 9 | jest.mock('@actions/core', () => ({ 10 | setFailed: jest.fn(), 11 | getInput: jest.fn(), 12 | isDebug: jest.fn(), 13 | info: jest.fn() 14 | })) 15 | 16 | /* 17 | Test generateWorktree against a known git repository. 18 | The upstream repository `origin` is set up once for the test suite, 19 | and for each test run, a new clone is created. 20 | 21 | See workstree.error.test.ts for testing mocked errors from git.*/ 22 | 23 | describe('generateWorktree', () => { 24 | let tempdir: string | null = null 25 | let clonedir: string | null = null 26 | beforeAll(async () => { 27 | // Set up origin repository 28 | const silent = true 29 | tempdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-deploy-')) 30 | const origin = path.join(tempdir, 'origin') 31 | await execute('git init origin', tempdir, silent) 32 | await execute('git config user.email "you@example.com"', origin, silent) 33 | await execute('git config user.name "Jane Doe"', origin, silent) 34 | await execute('git checkout -b main', origin, silent) 35 | fs.writeFileSync(path.join(origin, 'f1'), 'hello world\n') 36 | await execute('git add .', origin, silent) 37 | await execute('git commit -mc0', origin, silent) 38 | fs.writeFileSync(path.join(origin, 'f1'), 'hello world\nand planets\n') 39 | await execute('git add .', origin, silent) 40 | await execute('git commit -mc1', origin, silent) 41 | await execute('git checkout --orphan gh-pages', origin, silent) 42 | await execute('git reset --hard', origin, silent) 43 | await fs.promises.writeFile(path.join(origin, 'gh1'), 'pages content\n') 44 | await execute('git add .', origin, silent) 45 | await execute('git commit -mgh0', origin, silent) 46 | await fs.promises.writeFile( 47 | path.join(origin, 'gh1'), 48 | 'pages content\ngoes on\n' 49 | ) 50 | await execute('git add .', origin, silent) 51 | await execute('git commit -mgh1', origin, silent) 52 | }) 53 | beforeEach(async () => { 54 | // Clone origin to our workspace for each test 55 | const silent = true 56 | clonedir = path.join(tempdir as string, 'clone') 57 | await execute('git init clone', tempdir as string, silent) 58 | await execute('git config user.email "you@example.com"', clonedir, silent) 59 | await execute('git config user.name "Jane Doe"', clonedir, silent) 60 | await execute( 61 | `git remote add origin ${path.join(tempdir as string, 'origin')}`, 62 | clonedir, 63 | silent 64 | ) 65 | await execute('git fetch --depth=1 origin main', clonedir, silent) 66 | await execute('git checkout main', clonedir, silent) 67 | }) 68 | afterEach(async () => { 69 | // Tear down workspace 70 | await rmRF(clonedir as string) 71 | }) 72 | afterAll(async () => { 73 | // Tear down origin repository 74 | if (tempdir) { 75 | await rmRF(tempdir) 76 | // console.log(tempdir) 77 | } 78 | }) 79 | describe('with existing branch and new commits', () => { 80 | it('should check out the latest commit', async () => { 81 | const workspace = clonedir as string 82 | await generateWorktree( 83 | { 84 | hostname: 'github.com', 85 | workspace, 86 | singleCommit: false, 87 | branch: 'gh-pages', 88 | folder: '', 89 | silent: true, 90 | isTest: TestFlag.NONE 91 | }, 92 | 'worktree', 93 | true 94 | ) 95 | const dirEntries = await fs.promises.readdir( 96 | path.join(workspace, 'worktree') 97 | ) 98 | expect(dirEntries.sort((a, b) => a.localeCompare(b))).toEqual([ 99 | '.git', 100 | 'gh1' 101 | ]) 102 | const commitMessages = await execute( 103 | 'git log --format=%s', 104 | path.join(workspace, 'worktree'), 105 | true 106 | ) 107 | expect(commitMessages).toBe('gh1') 108 | }) 109 | }) 110 | describe('with missing branch and new commits', () => { 111 | it('should create initial commit', async () => { 112 | const workspace = clonedir as string 113 | await generateWorktree( 114 | { 115 | hostname: 'github.com', 116 | workspace, 117 | singleCommit: false, 118 | branch: 'no-pages', 119 | folder: '', 120 | silent: true, 121 | isTest: TestFlag.NONE 122 | }, 123 | 'worktree', 124 | false 125 | ) 126 | const dirEntries = await fs.promises.readdir( 127 | path.join(workspace, 'worktree') 128 | ) 129 | expect(dirEntries).toEqual(['.git']) 130 | const commitMessages = await execute( 131 | 'git log --format=%s', 132 | path.join(workspace, 'worktree'), 133 | true 134 | ) 135 | expect(commitMessages).toBe('Initial no-pages commit') 136 | }) 137 | }) 138 | describe('with existing branch and singleCommit', () => { 139 | it('should check out the latest commit', async () => { 140 | const workspace = clonedir as string 141 | await generateWorktree( 142 | { 143 | hostname: 'github.com', 144 | workspace, 145 | singleCommit: true, 146 | branch: 'gh-pages', 147 | folder: '', 148 | silent: true, 149 | isTest: TestFlag.NONE 150 | }, 151 | 'worktree', 152 | true 153 | ) 154 | const dirEntries = await fs.promises.readdir( 155 | path.join(workspace, 'worktree') 156 | ) 157 | expect(dirEntries.sort((a, b) => a.localeCompare(b))).toEqual([ 158 | '.git', 159 | 'gh1' 160 | ]) 161 | expect(async () => { 162 | await execute( 163 | 'git log --format=%s', 164 | path.join(workspace, 'worktree'), 165 | true 166 | ) 167 | }).rejects.toThrow() 168 | }) 169 | }) 170 | describe('with missing branch and singleCommit', () => { 171 | it('should create initial commit', async () => { 172 | const workspace = clonedir as string 173 | await generateWorktree( 174 | { 175 | hostname: 'github.com', 176 | workspace, 177 | singleCommit: true, 178 | branch: 'no-pages', 179 | folder: '', 180 | silent: true, 181 | isTest: TestFlag.NONE 182 | }, 183 | 'worktree', 184 | false 185 | ) 186 | const dirEntries = await fs.promises.readdir( 187 | path.join(workspace, 'worktree') 188 | ) 189 | expect(dirEntries).toEqual(['.git']) 190 | expect(async () => { 191 | await execute( 192 | 'git log --format=%s', 193 | path.join(workspace, 'worktree'), 194 | true 195 | ) 196 | }).rejects.toThrow() 197 | }) 198 | }) 199 | }) 200 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import {info} from '@actions/core' 2 | import {mkdirP, rmRF} from '@actions/io' 3 | import fs from 'fs' 4 | import {ActionInterface, Status, TestFlag} from './constants' 5 | import {execute} from './execute' 6 | import {generateWorktree} from './worktree' 7 | import { 8 | extractErrorMessage, 9 | isNullOrUndefined, 10 | suppressSensitiveInformation 11 | } from './util' 12 | 13 | /* Initializes git in the workspace. */ 14 | export async function init(action: ActionInterface): Promise { 15 | try { 16 | info(`Deploying using ${action.tokenType}… 🔑`) 17 | info('Configuring git…') 18 | 19 | await execute( 20 | `git config user.name "${action.name}"`, 21 | action.workspace, 22 | action.silent 23 | ) 24 | await execute( 25 | `git config user.email "${action.email ? action.email : '<>'}"`, 26 | action.workspace, 27 | action.silent 28 | ) 29 | 30 | try { 31 | if ((process.env.CI && !action.sshKey) || action.isTest) { 32 | /* Ensures that previously set Git configs do not interfere with the deployment. 33 | Only runs in the GitHub Actions CI environment if a user is not using an SSH key. 34 | */ 35 | await execute( 36 | `git config --local --unset-all http.https://${action.hostname}/.extraheader`, 37 | action.workspace, 38 | action.silent 39 | ) 40 | } 41 | 42 | if (action.isTest === TestFlag.UNABLE_TO_UNSET_GIT_CONFIG) { 43 | throw new Error() 44 | } 45 | } catch { 46 | info( 47 | 'Unable to unset previous git config authentication as it may not exist, continuing…' 48 | ) 49 | } 50 | 51 | try { 52 | await execute(`git remote rm origin`, action.workspace, action.silent) 53 | 54 | if (action.isTest === TestFlag.UNABLE_TO_REMOVE_ORIGIN) { 55 | throw new Error() 56 | } 57 | } catch { 58 | info('Attempted to remove origin but failed, continuing…') 59 | } 60 | 61 | await execute( 62 | `git remote add origin ${action.repositoryPath}`, 63 | action.workspace, 64 | action.silent 65 | ) 66 | info('Git configured… 🔧') 67 | } catch (error) { 68 | throw new Error( 69 | `There was an error initializing the repository: ${suppressSensitiveInformation( 70 | extractErrorMessage(error), 71 | action 72 | )} ❌` 73 | ) 74 | } 75 | } 76 | 77 | /* Runs the necessary steps to make the deployment. */ 78 | export async function deploy(action: ActionInterface): Promise { 79 | const temporaryDeploymentDirectory = 80 | 'github-pages-deploy-action-temp-deployment-folder' 81 | const temporaryDeploymentBranch = `github-pages-deploy-action/${Math.random() 82 | .toString(36) 83 | .substr(2, 9)}` 84 | 85 | info('Starting to commit changes…') 86 | 87 | try { 88 | const commitMessage = !isNullOrUndefined(action.commitMessage) 89 | ? (action.commitMessage as string) 90 | : `Deploying to ${action.branch}${ 91 | process.env.GITHUB_SHA 92 | ? ` from @ ${process.env.GITHUB_REPOSITORY}@${process.env.GITHUB_SHA}` 93 | : '' 94 | } 🚀` 95 | 96 | // Checks to see if the remote exists prior to deploying. 97 | const branchExists = 98 | action.isTest & TestFlag.HAS_REMOTE_BRANCH || 99 | (await execute( 100 | `git ls-remote --heads ${action.repositoryPath} refs/heads/${action.branch}`, 101 | action.workspace, 102 | action.silent 103 | )) 104 | 105 | await generateWorktree(action, temporaryDeploymentDirectory, branchExists) 106 | 107 | // Ensures that items that need to be excluded from the clean job get parsed. 108 | let excludes = '' 109 | if (action.clean && action.cleanExclude) { 110 | for (const item of action.cleanExclude) { 111 | excludes += `--exclude ${item} ` 112 | } 113 | } 114 | 115 | if (action.targetFolder) { 116 | info(`Creating target folder if it doesn't already exist… 📌`) 117 | await mkdirP(`${temporaryDeploymentDirectory}/${action.targetFolder}`) 118 | } 119 | 120 | /* 121 | Pushes all of the build files into the deployment directory. 122 | Allows the user to specify the root if '.' is provided. 123 | rsync is used to prevent file duplication. */ 124 | await execute( 125 | `rsync -q -av --checksum --progress ${action.folderPath}/. ${ 126 | action.targetFolder 127 | ? `${temporaryDeploymentDirectory}/${action.targetFolder}` 128 | : temporaryDeploymentDirectory 129 | } ${ 130 | action.clean 131 | ? `--delete ${excludes} ${ 132 | !fs.existsSync(`${action.folderPath}/CNAME`) 133 | ? '--exclude CNAME' 134 | : '' 135 | } ${ 136 | !fs.existsSync(`${action.folderPath}/.nojekyll`) 137 | ? '--exclude .nojekyll' 138 | : '' 139 | }` 140 | : '' 141 | } --exclude .ssh --exclude .git --exclude .github ${ 142 | action.folderPath === action.workspace 143 | ? `--exclude ${temporaryDeploymentDirectory}` 144 | : '' 145 | }`, 146 | action.workspace, 147 | action.silent 148 | ) 149 | 150 | if (action.singleCommit) { 151 | await execute( 152 | `git add --all .`, 153 | `${action.workspace}/${temporaryDeploymentDirectory}`, 154 | action.silent 155 | ) 156 | } 157 | 158 | // Use git status to check if we have something to commit. 159 | // Special case is singleCommit with existing history, when 160 | // we're really interested if the diff against the upstream branch 161 | // changed. 162 | const checkGitStatus = 163 | branchExists && action.singleCommit 164 | ? `git diff origin/${action.branch}` 165 | : `git status --porcelain` 166 | 167 | info(`Checking if there are files to commit…`) 168 | 169 | const hasFilesToCommit = 170 | action.isTest & TestFlag.HAS_CHANGED_FILES || 171 | (await execute( 172 | checkGitStatus, 173 | `${action.workspace}/${temporaryDeploymentDirectory}`, 174 | true // This output is always silenced due to the large output it creates. 175 | )) 176 | 177 | if ( 178 | (!action.singleCommit && !hasFilesToCommit) || 179 | // Ignores the case where single commit is true with a target folder to prevent incorrect early exiting. 180 | (action.singleCommit && !action.targetFolder && !hasFilesToCommit) 181 | ) { 182 | return Status.SKIPPED 183 | } 184 | 185 | // Commits to GitHub. 186 | await execute( 187 | `git add --all .`, 188 | `${action.workspace}/${temporaryDeploymentDirectory}`, 189 | action.silent 190 | ) 191 | await execute( 192 | `git checkout -b ${temporaryDeploymentBranch}`, 193 | `${action.workspace}/${temporaryDeploymentDirectory}`, 194 | action.silent 195 | ) 196 | await execute( 197 | `git commit -m "${commitMessage}" --quiet --no-verify`, 198 | `${action.workspace}/${temporaryDeploymentDirectory}`, 199 | action.silent 200 | ) 201 | if (!action.dryRun) { 202 | await execute( 203 | `git push --force ${action.repositoryPath} ${temporaryDeploymentBranch}:${action.branch}`, 204 | `${action.workspace}/${temporaryDeploymentDirectory}`, 205 | action.silent 206 | ) 207 | } 208 | 209 | info(`Changes committed to the ${action.branch} branch… 📦`) 210 | 211 | return Status.SUCCESS 212 | } catch (error) { 213 | throw new Error( 214 | `The deploy step encountered an error: ${suppressSensitiveInformation( 215 | extractErrorMessage(error), 216 | action 217 | )} ❌` 218 | ) 219 | } finally { 220 | // Cleans up temporary files/folders and restores the git state. 221 | info('Running post deployment cleanup jobs… 🗑️') 222 | 223 | await execute( 224 | `git checkout -B ${temporaryDeploymentBranch}`, 225 | `${action.workspace}/${temporaryDeploymentDirectory}`, 226 | action.silent 227 | ) 228 | 229 | await execute( 230 | `chmod -R 777 ${temporaryDeploymentDirectory}`, 231 | action.workspace, 232 | action.silent 233 | ) 234 | 235 | await execute( 236 | `git worktree remove ${temporaryDeploymentDirectory} --force`, 237 | action.workspace, 238 | action.silent 239 | ) 240 | 241 | await rmRF(temporaryDeploymentDirectory) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: integration-tests 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: 'Specifies the branch which the integration tests should run on.' 7 | required: true 8 | default: 'releases/v4' 9 | schedule: 10 | - cron: 30 15 * * 0-6 11 | push: 12 | tags-ignore: 13 | - '*.*' 14 | branches: 15 | - releases/v4 16 | 17 | jobs: 18 | # Deploys cross repo with an access token. 19 | integration-cross-repo-push: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v2 24 | 25 | - name: Build and Deploy 26 | uses: JamesIves/github-pages-deploy-action@releases/v4 27 | with: 28 | git-config-name: Montezuma 29 | git-config-email: montezuma@jamesiv.es 30 | repository-name: MontezumaIves/lab 31 | token: ${{ secrets.ACCESS_TOKEN }} 32 | branch: gh-pages 33 | folder: integration 34 | single-commit: true 35 | clean: true 36 | silent: true 37 | 38 | # Deploys using checkout@v1 with an ACCESS_TOKEN. 39 | integration-checkout-v1: 40 | needs: integration-cross-repo-push 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v1 45 | 46 | - name: Build and Deploy 47 | uses: JamesIves/github-pages-deploy-action@releases/v4 48 | with: 49 | token: ${{ secrets.ACCESS_TOKEN }} 50 | branch: gh-pages 51 | folder: integration 52 | target-folder: cat/montezuma 53 | git-config-name: Montezuma 54 | git-config-email: montezuma@jamesiv.es 55 | silent: true 56 | 57 | - name: Cleanup Generated Branch 58 | uses: dawidd6/action-delete-branch@v2.0.1 59 | with: 60 | github_token: ${{ secrets.GITHUB_TOKEN }} 61 | branches: gh-pages 62 | 63 | # Deploys using checkout@v2 with a GITHUB_TOKEN. 64 | integration-checkout-v2: 65 | needs: integration-checkout-v1 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Checkout 69 | uses: actions/checkout@v2 70 | with: 71 | persist-credentials: false 72 | 73 | - name: Build and Deploy 74 | uses: JamesIves/github-pages-deploy-action@releases/v4 75 | with: 76 | branch: gh-pages 77 | folder: integration 78 | target-folder: cat/montezuma2 79 | silent: true 80 | 81 | - name: Cleanup Generated Branch 82 | uses: dawidd6/action-delete-branch@v2.0.1 83 | with: 84 | github_token: ${{ secrets.GITHUB_TOKEN }} 85 | branches: gh-pages 86 | 87 | # Deploys using a container that requires you to install rsync. 88 | integration-container: 89 | needs: integration-checkout-v2 90 | runs-on: ubuntu-latest 91 | container: 92 | image: ruby:2.6 93 | env: 94 | LANG: C.UTF-8 95 | steps: 96 | - name: Checkout 97 | uses: actions/checkout@v2 98 | with: 99 | persist-credentials: false 100 | 101 | - name: Install rsync 102 | run: | 103 | apt-get update && apt-get install -y rsync 104 | 105 | - name: Build and Deploy 106 | uses: JamesIves/github-pages-deploy-action@releases/v4 107 | with: 108 | branch: gh-pages 109 | folder: integration 110 | target-folder: cat/montezuma2 111 | silent: true 112 | 113 | - name: Cleanup Generated Branch 114 | uses: dawidd6/action-delete-branch@v2.0.1 115 | with: 116 | github_token: ${{ secrets.GITHUB_TOKEN }} 117 | branches: gh-pages 118 | 119 | # Deploys using an SSH key. 120 | integration-ssh: 121 | needs: integration-container 122 | runs-on: ubuntu-latest 123 | steps: 124 | - name: Checkout 125 | uses: actions/checkout@v2 126 | with: 127 | persist-credentials: false 128 | 129 | - name: Build and Deploy 130 | uses: JamesIves/github-pages-deploy-action@releases/v4 131 | with: 132 | ssh-key: ${{ secrets.DEPLOY_KEY }} 133 | branch: gh-pages 134 | folder: integration 135 | target-folder: cat/montezuma3 136 | silent: true 137 | 138 | - name: Cleanup Generated Branch 139 | uses: dawidd6/action-delete-branch@v2.0.1 140 | with: 141 | github_token: ${{ secrets.GITHUB_TOKEN }} 142 | branches: gh-pages 143 | 144 | # Deploys using an SSH key. 145 | integration-ssh-third-party-client: 146 | needs: integration-ssh 147 | runs-on: ubuntu-latest 148 | steps: 149 | - name: Checkout 150 | uses: actions/checkout@v2 151 | with: 152 | persist-credentials: false 153 | 154 | - name: Install SSH Client 155 | uses: webfactory/ssh-agent@v0.4.1 156 | with: 157 | ssh-private-key: ${{ secrets.DEPLOY_KEY }} 158 | 159 | - name: Build and Deploy 160 | uses: JamesIves/github-pages-deploy-action@releases/v4 161 | with: 162 | ssh-key: true 163 | branch: gh-pages 164 | folder: integration 165 | target-folder: cat/montezuma4 166 | silent: true 167 | 168 | - name: Cleanup Generated Branch 169 | uses: dawidd6/action-delete-branch@v2.0.1 170 | with: 171 | github_token: ${{ secrets.GITHUB_TOKEN }} 172 | branches: gh-pages 173 | 174 | # Deploys using a custom env. (Includes subsequent commit) 175 | integration-env: 176 | needs: integration-ssh-third-party-client 177 | runs-on: ubuntu-latest 178 | steps: 179 | - uses: actions/setup-node@v1.4.4 180 | with: 181 | node-version: 'v14.18.1' 182 | 183 | - name: Checkout 184 | uses: actions/checkout@v2 185 | with: 186 | persist-credentials: false 187 | 188 | - name: Build and Deploy 189 | uses: JamesIves/github-pages-deploy-action@releases/v4 190 | with: 191 | ssh-key: ${{ secrets.DEPLOY_KEY }} 192 | branch: gh-pages 193 | folder: integration 194 | target-folder: cat/montezuma4 195 | silent: true 196 | 197 | - name: Build and Deploy 198 | uses: JamesIves/github-pages-deploy-action@releases/v4 199 | with: 200 | ssh-key: ${{ secrets.DEPLOY_KEY }} 201 | branch: gh-pages 202 | folder: integration 203 | target-folder: cat/subsequent 204 | silent: true 205 | 206 | - name: Cleanup Generated Branch 207 | uses: dawidd6/action-delete-branch@v2.0.1 208 | with: 209 | github_token: ${{ secrets.GITHUB_TOKEN }} 210 | branches: gh-pages 211 | 212 | # Deploys using the CLEAN option. 213 | integration-clean: 214 | needs: 215 | [ 216 | integration-checkout-v1, 217 | integration-checkout-v2, 218 | integration-container, 219 | integration-ssh, 220 | integration-ssh-third-party-client, 221 | integration-env 222 | ] 223 | runs-on: ubuntu-latest 224 | steps: 225 | - name: Checkout 226 | uses: actions/checkout@v2 227 | with: 228 | persist-credentials: false 229 | 230 | - name: Build and Deploy 231 | uses: JamesIves/github-pages-deploy-action@releases/v4 232 | with: 233 | token: ${{ secrets.ACCESS_TOKEN }} 234 | branch: gh-pages 235 | folder: integration 236 | clean: true 237 | silent: true 238 | 239 | # Deploys to a branch that doesn't exist with SINGLE_COMMIT. (Includes subsequent commit) 240 | integration-branch-creation: 241 | needs: integration-clean 242 | runs-on: ubuntu-latest 243 | steps: 244 | - name: Checkout 245 | uses: actions/checkout@v2 246 | with: 247 | persist-credentials: false 248 | 249 | - name: Build and Deploy 250 | uses: JamesIves/github-pages-deploy-action@releases/v4 251 | with: 252 | token: ${{ secrets.ACCESS_TOKEN }} 253 | branch: integration-test-delete-prod 254 | folder: integration 255 | single-commit: true 256 | silent: true 257 | 258 | - name: Build and Deploy 259 | uses: JamesIves/github-pages-deploy-action@releases/v4 260 | with: 261 | token: ${{ secrets.ACCESS_TOKEN }} 262 | branch: integration-test-delete-prod 263 | folder: integration 264 | single-commit: true 265 | target-folder: jives 266 | silent: true 267 | 268 | - name: Cleanup Generated Branch 269 | uses: dawidd6/action-delete-branch@v2.0.1 270 | with: 271 | github_token: ${{ secrets.GITHUB_TOKEN }} 272 | branches: integration-test-delete-prod 273 | -------------------------------------------------------------------------------- /__tests__/util.test.ts: -------------------------------------------------------------------------------- 1 | import {ActionInterface, TestFlag} from '../src/constants' 2 | import { 3 | isNullOrUndefined, 4 | generateTokenType, 5 | generateRepositoryPath, 6 | generateFolderPath, 7 | suppressSensitiveInformation, 8 | checkParameters, 9 | stripProtocolFromUrl, 10 | extractErrorMessage 11 | } from '../src/util' 12 | 13 | describe('util', () => { 14 | describe('isNullOrUndefined', () => { 15 | it('should return true if the value is null', async () => { 16 | const value = null 17 | expect(isNullOrUndefined(value)).toBeTruthy() 18 | }) 19 | 20 | it('should return true if the value is undefined', async () => { 21 | const value = undefined 22 | expect(isNullOrUndefined(value)).toBeTruthy() 23 | }) 24 | 25 | it('should return false if the value is defined', async () => { 26 | const value = 'montezuma' 27 | expect(isNullOrUndefined(value)).toBeFalsy() 28 | }) 29 | 30 | it('should return false if the value is empty string', async () => { 31 | const value = '' 32 | expect(isNullOrUndefined(value)).toBeTruthy() 33 | }) 34 | 35 | it('should return true if the value is null (with allowEmptyString)', async () => { 36 | const value = null 37 | expect(isNullOrUndefined(value, true)).toBeTruthy() 38 | }) 39 | 40 | it('should return true if the value is undefined (with allowEmptyString)', async () => { 41 | const value = undefined 42 | expect(isNullOrUndefined(value, true)).toBeTruthy() 43 | }) 44 | 45 | it('should return false if the value is defined (with allowEmptyString)', async () => { 46 | const value = 'montezuma' 47 | expect(isNullOrUndefined(value, true)).toBeFalsy() 48 | }) 49 | 50 | it('should return false if the value is empty string (with allowEmptyString)', async () => { 51 | const value = '' 52 | expect(isNullOrUndefined(value, true)).toBeFalsy() 53 | }) 54 | }) 55 | 56 | describe('generateTokenType', () => { 57 | it('should return ssh if ssh is provided', async () => { 58 | const action = { 59 | branch: '123', 60 | workspace: 'src/', 61 | folder: 'build', 62 | token: null, 63 | sshKey: 'real_token', 64 | silent: false, 65 | isTest: TestFlag.NONE 66 | } 67 | expect(generateTokenType(action)).toEqual('SSH Deploy Key') 68 | }) 69 | 70 | it('should return deploy token if token is provided', async () => { 71 | const action = { 72 | branch: '123', 73 | workspace: 'src/', 74 | folder: 'build', 75 | token: '123', 76 | sshKey: null, 77 | silent: false, 78 | isTest: TestFlag.NONE 79 | } 80 | expect(generateTokenType(action)).toEqual('Deploy Token') 81 | }) 82 | 83 | it('should return ... if no token is provided', async () => { 84 | const action = { 85 | branch: '123', 86 | workspace: 'src/', 87 | folder: 'build', 88 | token: null, 89 | sshKey: null, 90 | silent: false, 91 | isTest: TestFlag.NONE 92 | } 93 | expect(generateTokenType(action)).toEqual('…') 94 | }) 95 | }) 96 | 97 | describe('generateRepositoryPath', () => { 98 | it('should return ssh if ssh is provided', async () => { 99 | const action = { 100 | repositoryName: 'JamesIves/github-pages-deploy-action', 101 | branch: '123', 102 | workspace: 'src/', 103 | folder: 'build', 104 | hostname: 'github.com', 105 | token: null, 106 | sshKey: 'real_token', 107 | silent: false, 108 | isTest: TestFlag.NONE 109 | } 110 | 111 | expect(generateRepositoryPath(action)).toEqual( 112 | 'git@github.com:JamesIves/github-pages-deploy-action' 113 | ) 114 | }) 115 | 116 | it('should return https with x-access-token if deploy token is provided', async () => { 117 | const action = { 118 | repositoryName: 'JamesIves/github-pages-deploy-action', 119 | branch: '123', 120 | workspace: 'src/', 121 | folder: 'build', 122 | hostname: 'enterprise.github.com', 123 | token: '123', 124 | sshKey: null, 125 | silent: false, 126 | isTest: TestFlag.NONE 127 | } 128 | 129 | expect(generateRepositoryPath(action)).toEqual( 130 | 'https://x-access-token:123@enterprise.github.com/JamesIves/github-pages-deploy-action.git' 131 | ) 132 | }) 133 | 134 | describe('suppressSensitiveInformation', () => { 135 | it('should replace any sensitive information with ***', async () => { 136 | const action = { 137 | repositoryName: 'JamesIves/github-pages-deploy-action', 138 | repositoryPath: 139 | 'https://x-access-token:supersecret999%%%@github.com/anothersecret123333', 140 | branch: '123', 141 | workspace: 'src/', 142 | folder: 'build', 143 | token: 'anothersecret123333', 144 | silent: false, 145 | isTest: TestFlag.NONE 146 | } 147 | 148 | const string = `This is an error message! It contains ${action.token} and ${action.repositoryPath} and ${action.token} again!` 149 | expect(suppressSensitiveInformation(string, action)).toBe( 150 | 'This is an error message! It contains *** and *** and *** again!' 151 | ) 152 | }) 153 | 154 | it('should not suppress information when in debug mode', async () => { 155 | const action = { 156 | repositoryName: 'JamesIves/github-pages-deploy-action', 157 | repositoryPath: 158 | 'https://x-access-token:supersecret999%%%@github.com/anothersecret123333', 159 | branch: '123', 160 | workspace: 'src/', 161 | folder: 'build', 162 | token: 'anothersecret123333', 163 | silent: false, 164 | isTest: TestFlag.NONE 165 | } 166 | 167 | process.env['RUNNER_DEBUG'] = '1' 168 | 169 | const string = `This is an error message! It contains ${action.token} and ${action.repositoryPath}` 170 | expect(suppressSensitiveInformation(string, action)).toBe( 171 | 'This is an error message! It contains anothersecret123333 and https://x-access-token:supersecret999%%%@github.com/anothersecret123333' 172 | ) 173 | }) 174 | }) 175 | }) 176 | 177 | describe('generateFolderPath', () => { 178 | it('should return absolute path if folder name is provided', () => { 179 | const action = { 180 | branch: '123', 181 | workspace: 'src/', 182 | folder: 'build', 183 | token: null, 184 | sshKey: null, 185 | silent: false, 186 | isTest: TestFlag.NONE 187 | } 188 | expect(generateFolderPath(action)).toEqual('src/build') 189 | }) 190 | 191 | it('should return original path if folder name begins with /', () => { 192 | const action = { 193 | branch: '123', 194 | workspace: 'src/', 195 | folder: '/home/user/repo/build', 196 | token: null, 197 | sshKey: null, 198 | silent: false, 199 | isTest: TestFlag.NONE 200 | } 201 | expect(generateFolderPath(action)).toEqual('/home/user/repo/build') 202 | }) 203 | 204 | it('should process as relative path if folder name begins with ./', () => { 205 | const action = { 206 | branch: '123', 207 | workspace: 'src/', 208 | folder: './build', 209 | token: null, 210 | sshKey: null, 211 | silent: false, 212 | isTest: TestFlag.NONE 213 | } 214 | expect(generateFolderPath(action)).toEqual('src/build') 215 | }) 216 | 217 | it('should return absolute path if folder name begins with ~', () => { 218 | const action = { 219 | branch: '123', 220 | workspace: 'src/', 221 | folder: '~/repo/build', 222 | token: null, 223 | sshKey: null, 224 | silent: false, 225 | isTest: TestFlag.NONE 226 | } 227 | process.env.HOME = '/home/user' 228 | expect(generateFolderPath(action)).toEqual('/home/user/repo/build') 229 | }) 230 | }) 231 | 232 | describe('hasRequiredParameters', () => { 233 | it('should fail if there is no provided GitHub Token, Access Token or SSH bool', () => { 234 | const action = { 235 | silent: false, 236 | repositoryPath: undefined, 237 | branch: 'branch', 238 | folder: 'build', 239 | workspace: 'src/', 240 | isTest: TestFlag.NONE 241 | } 242 | 243 | try { 244 | checkParameters(action) 245 | } catch (e) { 246 | expect(e instanceof Error && e.message).toMatch( 247 | '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.' 248 | ) 249 | } 250 | }) 251 | 252 | it('should fail if token is defined but it is an empty string', () => { 253 | const action = { 254 | silent: false, 255 | repositoryPath: undefined, 256 | token: '', 257 | branch: 'branch', 258 | folder: 'build', 259 | workspace: 'src/', 260 | isTest: TestFlag.NONE 261 | } 262 | 263 | try { 264 | checkParameters(action) 265 | } catch (e) { 266 | expect(e instanceof Error && e.message).toMatch( 267 | '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.' 268 | ) 269 | } 270 | }) 271 | 272 | it('should fail if there is no branch', () => { 273 | const action = { 274 | silent: false, 275 | repositoryPath: undefined, 276 | token: '123', 277 | branch: '', 278 | folder: 'build', 279 | workspace: 'src/', 280 | isTest: TestFlag.NONE 281 | } 282 | 283 | try { 284 | checkParameters(action) 285 | } catch (e) { 286 | expect(e instanceof Error && e.message).toMatch('Branch is required.') 287 | } 288 | }) 289 | 290 | it('should fail if there is no folder', () => { 291 | const action = { 292 | silent: false, 293 | repositoryPath: undefined, 294 | token: '123', 295 | branch: 'branch', 296 | folder: '', 297 | workspace: 'src/', 298 | isTest: TestFlag.NONE 299 | } 300 | 301 | try { 302 | checkParameters(action) 303 | } catch (e) { 304 | expect(e instanceof Error && e.message).toMatch( 305 | 'You must provide the action with a folder to deploy.' 306 | ) 307 | } 308 | }) 309 | 310 | it('should fail if the folder does not exist in the tree', () => { 311 | const action: ActionInterface = { 312 | silent: false, 313 | repositoryPath: undefined, 314 | token: '123', 315 | branch: 'branch', 316 | folder: 'notARealFolder', 317 | workspace: '.', 318 | isTest: TestFlag.NONE 319 | } 320 | 321 | try { 322 | action.folderPath = generateFolderPath(action) 323 | checkParameters(action) 324 | } catch (e) { 325 | expect(e instanceof Error && e.message).toMatch( 326 | `The directory you're trying to deploy named notARealFolder doesn't exist. Please double check the path and any prerequisite build scripts and try again. ❗` 327 | ) 328 | } 329 | }) 330 | }) 331 | 332 | describe('stripProtocolFromUrl', () => { 333 | it('removes https', () => { 334 | expect(stripProtocolFromUrl('https://github.com')).toBe('github.com') 335 | }) 336 | 337 | it('removes http', () => { 338 | expect(stripProtocolFromUrl('http://github.com')).toBe('github.com') 339 | }) 340 | 341 | it('removes https|http and www.', () => { 342 | expect(stripProtocolFromUrl('http://www.github.com')).toBe('github.com') 343 | }) 344 | 345 | it('works with a url that is not github.com', () => { 346 | expect(stripProtocolFromUrl('http://github.enterprise.jamesiv.es')).toBe( 347 | 'github.enterprise.jamesiv.es' 348 | ) 349 | }) 350 | }) 351 | 352 | describe('extractErrorMessage', () => { 353 | it('gets the message of a Error', () => { 354 | expect(extractErrorMessage(new Error('a error message'))).toBe( 355 | 'a error message' 356 | ) 357 | }) 358 | 359 | it('gets the message of a string', () => { 360 | expect(extractErrorMessage('a error message')).toBe('a error message') 361 | }) 362 | 363 | it('gets the message of a object', () => { 364 | expect(extractErrorMessage({special: 'a error message'})).toBe( 365 | `{"special":"a error message"}` 366 | ) 367 | }) 368 | }) 369 | }) 370 | -------------------------------------------------------------------------------- /__tests__/git.test.ts: -------------------------------------------------------------------------------- 1 | // Initial env variable setup for tests. 2 | process.env['INPUT_FOLDER'] = 'build' 3 | process.env['GITHUB_SHA'] = '123' 4 | 5 | import {mkdirP, rmRF} from '@actions/io' 6 | import {action, Status, TestFlag} from '../src/constants' 7 | import {execute} from '../src/execute' 8 | import {deploy, init} from '../src/git' 9 | import fs from 'fs' 10 | 11 | const originalAction = JSON.stringify(action) 12 | 13 | jest.mock('fs', () => ({ 14 | existsSync: jest.fn() 15 | })) 16 | 17 | jest.mock('@actions/core', () => ({ 18 | setFailed: jest.fn(), 19 | getInput: jest.fn(), 20 | setOutput: jest.fn(), 21 | isDebug: jest.fn(), 22 | info: jest.fn() 23 | })) 24 | 25 | jest.mock('@actions/io', () => ({ 26 | rmRF: jest.fn(), 27 | mkdirP: jest.fn() 28 | })) 29 | 30 | jest.mock('../src/execute', () => ({ 31 | // eslint-disable-next-line @typescript-eslint/naming-convention 32 | __esModule: true, 33 | execute: jest.fn() 34 | })) 35 | 36 | describe('git', () => { 37 | afterEach(() => { 38 | Object.assign(action, JSON.parse(originalAction)) 39 | }) 40 | 41 | describe('init', () => { 42 | it('should execute commands', async () => { 43 | Object.assign(action, { 44 | hostname: 'github.com', 45 | silent: false, 46 | repositoryPath: 'JamesIves/github-pages-deploy-action', 47 | token: '123', 48 | branch: 'branch', 49 | folder: '.', 50 | pusher: { 51 | name: 'asd', 52 | email: 'as@cat' 53 | }, 54 | isTest: TestFlag.HAS_CHANGED_FILES 55 | }) 56 | 57 | await init(action) 58 | expect(execute).toBeCalledTimes(5) 59 | }) 60 | 61 | it('should catch when a function throws an error', async () => { 62 | ;(execute as jest.Mock).mockImplementationOnce(() => { 63 | throw new Error('Mocked throw') 64 | }) 65 | 66 | Object.assign(action, { 67 | hostname: 'github.com', 68 | silent: false, 69 | repositoryPath: 'JamesIves/github-pages-deploy-action', 70 | token: '123', 71 | branch: 'branch', 72 | folder: '.', 73 | pusher: { 74 | name: 'asd', 75 | email: 'as@cat' 76 | }, 77 | isTest: TestFlag.HAS_CHANGED_FILES 78 | }) 79 | 80 | try { 81 | await init(action) 82 | } catch (error) { 83 | expect(error instanceof Error && error.message).toBe( 84 | 'There was an error initializing the repository: Mocked throw ❌' 85 | ) 86 | } 87 | }) 88 | 89 | it('should correctly continue when it cannot unset a git config value', async () => { 90 | Object.assign(action, { 91 | hostname: 'github.com', 92 | silent: false, 93 | repositoryPath: 'JamesIves/github-pages-deploy-action', 94 | token: '123', 95 | branch: 'branch', 96 | folder: '.', 97 | pusher: { 98 | name: 'asd', 99 | email: 'as@cat' 100 | }, 101 | isTest: TestFlag.UNABLE_TO_UNSET_GIT_CONFIG 102 | }) 103 | 104 | await init(action) 105 | expect(execute).toBeCalledTimes(5) 106 | }) 107 | 108 | it('should not unset git config if a user is using ssh', async () => { 109 | // Sets and unsets the CI condition. 110 | process.env.CI = 'true' 111 | 112 | Object.assign(action, { 113 | hostname: 'github.com', 114 | silent: false, 115 | repositoryPath: 'JamesIves/github-pages-deploy-action', 116 | sshKey: true, 117 | branch: 'branch', 118 | folder: '.', 119 | pusher: { 120 | name: 'asd', 121 | email: 'as@cat' 122 | }, 123 | isTest: false 124 | }) 125 | 126 | await init(action) 127 | expect(execute).toBeCalledTimes(4) 128 | 129 | process.env.CI = undefined 130 | }) 131 | 132 | it('should correctly continue when it cannot remove origin', async () => { 133 | Object.assign(action, { 134 | hostname: 'github.com', 135 | silent: false, 136 | repositoryPath: 'JamesIves/github-pages-deploy-action', 137 | token: '123', 138 | branch: 'branch', 139 | folder: '.', 140 | pusher: { 141 | name: 'asd', 142 | email: 'as@cat' 143 | }, 144 | isTest: TestFlag.UNABLE_TO_REMOVE_ORIGIN 145 | }) 146 | 147 | await init(action) 148 | expect(execute).toBeCalledTimes(5) 149 | }) 150 | }) 151 | 152 | describe('deploy', () => { 153 | it('should execute commands', async () => { 154 | Object.assign(action, { 155 | hostname: 'github.com', 156 | silent: false, 157 | folder: 'assets', 158 | branch: 'branch', 159 | token: '123', 160 | repositoryName: 'JamesIves/montezuma', 161 | pusher: { 162 | name: 'asd', 163 | email: 'as@cat' 164 | }, 165 | isTest: TestFlag.HAS_CHANGED_FILES 166 | }) 167 | 168 | const response = await deploy(action) 169 | 170 | // Includes the call to generateWorktree 171 | expect(execute).toBeCalledTimes(13) 172 | expect(rmRF).toBeCalledTimes(1) 173 | expect(response).toBe(Status.SUCCESS) 174 | }) 175 | 176 | it('should not push when asked to dryRun', async () => { 177 | Object.assign(action, { 178 | hostname: 'github.com', 179 | silent: false, 180 | dryRun: true, 181 | folder: 'assets', 182 | branch: 'branch', 183 | token: '123', 184 | pusher: { 185 | name: 'asd', 186 | email: 'as@cat' 187 | }, 188 | isTest: TestFlag.HAS_CHANGED_FILES 189 | }) 190 | 191 | const response = await deploy(action) 192 | 193 | // Includes the call to generateWorktree 194 | expect(execute).toBeCalledTimes(12) 195 | expect(rmRF).toBeCalledTimes(1) 196 | expect(response).toBe(Status.SUCCESS) 197 | }) 198 | 199 | it('should execute commands with single commit toggled', async () => { 200 | Object.assign(action, { 201 | hostname: 'github.com', 202 | silent: false, 203 | folder: 'other', 204 | folderPath: 'other', 205 | branch: 'branch', 206 | token: '123', 207 | singleCommit: true, 208 | pusher: { 209 | name: 'asd', 210 | email: 'as@cat' 211 | }, 212 | clean: true, 213 | isTest: TestFlag.HAS_CHANGED_FILES 214 | }) 215 | 216 | await deploy(action) 217 | 218 | // Includes the call to generateWorktree 219 | expect(execute).toBeCalledTimes(13) 220 | expect(rmRF).toBeCalledTimes(1) 221 | }) 222 | 223 | it('should execute commands with single commit toggled and existing branch', async () => { 224 | Object.assign(action, { 225 | hostname: 'github.com', 226 | silent: false, 227 | folder: 'other', 228 | folderPath: 'other', 229 | branch: 'branch', 230 | token: '123', 231 | singleCommit: true, 232 | pusher: { 233 | name: 'asd', 234 | email: 'as@cat' 235 | }, 236 | clean: true, 237 | isTest: TestFlag.HAS_CHANGED_FILES | TestFlag.HAS_REMOTE_BRANCH 238 | }) 239 | 240 | await deploy(action) 241 | 242 | // Includes the call to generateWorktree 243 | expect(execute).toBeCalledTimes(12) 244 | expect(rmRF).toBeCalledTimes(1) 245 | }) 246 | 247 | it('should execute commands with single commit and dryRun toggled', async () => { 248 | Object.assign(action, { 249 | hostname: 'github.com', 250 | silent: false, 251 | folder: 'other', 252 | folderPath: 'other', 253 | branch: 'branch', 254 | gitHubToken: '123', 255 | singleCommit: true, 256 | dryRun: true, 257 | pusher: { 258 | name: 'asd', 259 | email: 'as@cat' 260 | }, 261 | clean: true, 262 | isTest: TestFlag.HAS_CHANGED_FILES 263 | }) 264 | 265 | await deploy(action) 266 | 267 | // Includes the call to generateWorktree 268 | expect(execute).toBeCalledTimes(12) 269 | expect(rmRF).toBeCalledTimes(1) 270 | }) 271 | 272 | it('should not ignore CNAME or nojekyll if they exist in the deployment folder', async () => { 273 | ;(fs.existsSync as jest.Mock) 274 | .mockImplementationOnce(() => { 275 | return true 276 | }) 277 | .mockImplementationOnce(() => { 278 | return true 279 | }) 280 | 281 | Object.assign(action, { 282 | hostname: 'github.com', 283 | silent: false, 284 | folder: 'assets', 285 | folderPath: 'assets', 286 | branch: 'branch', 287 | token: '123', 288 | pusher: { 289 | name: 'asd', 290 | email: 'as@cat' 291 | }, 292 | clean: true, 293 | isTest: TestFlag.HAS_CHANGED_FILES 294 | }) 295 | 296 | const response = await deploy(action) 297 | 298 | // Includes the call to generateWorktree 299 | expect(execute).toBeCalledTimes(13) 300 | expect(rmRF).toBeCalledTimes(1) 301 | expect(fs.existsSync).toBeCalledTimes(2) 302 | expect(response).toBe(Status.SUCCESS) 303 | }) 304 | 305 | describe('with empty GITHUB_SHA', () => { 306 | const oldSha = process.env.GITHUB_SHA 307 | afterAll(() => { 308 | process.env.GITHUB_SHA = oldSha 309 | }) 310 | it('should execute commands with clean options', async () => { 311 | process.env.GITHUB_SHA = '' 312 | Object.assign(action, { 313 | hostname: 'github.com', 314 | silent: false, 315 | folder: 'other', 316 | folderPath: 'other', 317 | branch: 'branch', 318 | token: '123', 319 | pusher: { 320 | name: 'asd', 321 | email: 'as@cat' 322 | }, 323 | clean: true, 324 | workspace: 'other', 325 | isTest: TestFlag.NONE 326 | }) 327 | 328 | await deploy(action) 329 | 330 | // Includes the call to generateWorktree 331 | expect(execute).toBeCalledTimes(10) 332 | expect(rmRF).toBeCalledTimes(1) 333 | }) 334 | }) 335 | 336 | it('should execute commands with clean options stored as an array', async () => { 337 | Object.assign(action, { 338 | hostname: 'github.com', 339 | silent: false, 340 | folder: 'assets', 341 | folderPath: 'assets', 342 | branch: 'branch', 343 | token: '123', 344 | pusher: { 345 | name: 'asd', 346 | email: 'as@cat' 347 | }, 348 | clean: true, 349 | cleanExclude: ['cat', 'montezuma'], 350 | isTest: TestFlag.NONE 351 | }) 352 | 353 | await deploy(action) 354 | 355 | // Includes the call to generateWorktree 356 | expect(execute).toBeCalledTimes(10) 357 | expect(rmRF).toBeCalledTimes(1) 358 | }) 359 | 360 | it('should gracefully handle target folder', async () => { 361 | Object.assign(action, { 362 | hostname: 'github.com', 363 | silent: false, 364 | folder: '.', 365 | branch: 'branch', 366 | token: '123', 367 | pusher: {}, 368 | clean: true, 369 | targetFolder: 'new_folder', 370 | commitMessage: 'Hello!', 371 | isTest: TestFlag.NONE 372 | }) 373 | 374 | await deploy(action) 375 | 376 | expect(execute).toBeCalledTimes(10) 377 | expect(rmRF).toBeCalledTimes(1) 378 | expect(mkdirP).toBeCalledTimes(1) 379 | }) 380 | 381 | it('should stop early if there is nothing to commit', async () => { 382 | Object.assign(action, { 383 | hostname: 'github.com', 384 | silent: false, 385 | folder: 'assets', 386 | branch: 'branch', 387 | token: '123', 388 | pusher: { 389 | name: 'asd', 390 | email: 'as@cat' 391 | }, 392 | isTest: TestFlag.NONE // Setting this flag to None means there will never be anything to commit and the action will exit early. 393 | }) 394 | 395 | const response = await deploy(action) 396 | expect(execute).toBeCalledTimes(10) 397 | expect(rmRF).toBeCalledTimes(1) 398 | expect(response).toBe(Status.SKIPPED) 399 | }) 400 | 401 | it('should catch when a function throws an error', async () => { 402 | ;(execute as jest.Mock).mockImplementationOnce(() => { 403 | throw new Error('Mocked throw') 404 | }) 405 | 406 | Object.assign(action, { 407 | hostname: 'github.com', 408 | silent: false, 409 | folder: 'assets', 410 | branch: 'branch', 411 | token: '123', 412 | pusher: { 413 | name: 'asd', 414 | email: 'as@cat' 415 | }, 416 | isTest: TestFlag.HAS_CHANGED_FILES 417 | }) 418 | 419 | try { 420 | await deploy(action) 421 | } catch (error) { 422 | expect(error instanceof Error && error.message).toBe( 423 | 'The deploy step encountered an error: Mocked throw ❌' 424 | ) 425 | } 426 | }) 427 | }) 428 | }) 429 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 |

8 | GitHub Pages Deploy Action :rocket: 9 |

10 | 11 |

12 | 13 | Unit test status badge 14 | 15 | 16 | 17 | Integration test status badge 18 | 19 | 20 | 21 | Code coverage status badge 22 | 23 | 24 | 25 | Release version badge 26 | 27 | 28 | 29 | Github marketplace badge 30 | 31 |

32 | 33 |

34 | This GitHub Action will automatically deploy your project to GitHub Pages. It can be configured to push your production-ready code into any branch you'd like, including gh-pages and docs. It can also handle cross repository deployments and works with GitHub Enterprise too. 35 |

36 | 37 |

38 | 39 |

40 | 41 | ## Getting Started :airplane: 42 | 43 | You can include the action in your workflow to trigger on any event that [GitHub actions supports](https://help.github.com/en/articles/events-that-trigger-workflows). If the remote branch that you wish to deploy to doesn't already exist the action will create it for you. Your workflow will also need to include the `actions/checkout` step before this workflow runs in order for the deployment to work. 44 | 45 | You can view an example of this below. 46 | 47 | ```yml 48 | name: Build and Deploy 49 | on: [push] 50 | jobs: 51 | build-and-deploy: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 🛎️ 55 | uses: actions/checkout@v2.3.1 56 | 57 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 58 | run: | 59 | npm install 60 | npm run build 61 | 62 | - name: Deploy 🚀 63 | uses: JamesIves/github-pages-deploy-action@4.1.6 64 | with: 65 | branch: gh-pages # The branch the action should deploy to. 66 | folder: build # The folder the action should deploy. 67 | ``` 68 | 69 | If you'd like to make it so the workflow only triggers on push events to specific branches then you can modify the `on` section. 70 | 71 | ```yml 72 | on: 73 | push: 74 | branches: 75 | - main 76 | ``` 77 | 78 | It's recommended that you use [Dependabot](https://docs.github.com/en/code-security/supply-chain-security/managing-vulnerabilities-in-your-projects-dependencies/configuring-dependabot-security-updates) to keep your workflow up-to-date and [secure](https://github.com/features/security). You can find the latest tagged version on the [GitHub Marketplace](https://github.com/marketplace/actions/deploy-to-github-pages) or on the [releases page](https://github.com/JamesIves/github-pages-deploy-action/releases). 79 | 80 | #### Install as a Node Module 📦 81 | 82 | If you'd like to use the functionality provided by this action in your own action you can either [create a composite action](https://docs.github.com/en/actions/creating-actions/creating-a-composite-action), or you can install it using [yarn](https://yarnpkg.com/) or [npm](https://www.npmjs.com/get-npm) by running the following commands. It's available on both the [npm](https://www.npmjs.com/package/@jamesives/github-pages-deploy-action) and [GitHub registry](https://github.com/JamesIves/github-pages-deploy-action/packages/229985). 83 | 84 | ``` 85 | yarn add @jamesives/github-pages-deploy-action 86 | ``` 87 | 88 | ``` 89 | npm install @jamesives/github-pages-deploy-action 90 | ``` 91 | 92 | It can then be imported into your project like so. 93 | 94 | ```javascript 95 | import run from '@jamesives/github-pages-deploy-action' 96 | ``` 97 | 98 | Calling the functions directly will require you to pass in an object containing the variables found in the configuration section, you'll also need to provide a `workspace` with a path to your project. 99 | 100 | ```javascript 101 | import run from '@jamesives/github-pages-deploy-action' 102 | 103 | run({ 104 | token: process.env['ACCESS_TOKEN'], 105 | branch: 'gh-pages', 106 | folder: 'build', 107 | repositoryName: 'JamesIves/github-pages-deploy-action', 108 | silent: true, 109 | workspace: 'src/project/location' 110 | }) 111 | ``` 112 | 113 | For more information regarding the [action interface please click here](https://github.com/JamesIves/github-pages-deploy-action/blob/dev/src/constants.ts#L7). 114 | 115 | ## Configuration 📁 116 | 117 | The `with` portion of the workflow **must** be configured before the action will work. You can add these in the `with` section found in the examples above. Any `secrets` must be referenced using the bracket syntax and stored in the GitHub repository's `Settings/Secrets` menu. You can learn more about setting environment variables with GitHub actions [here](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#creating-encrypted-secrets). 118 | 119 | #### Required Setup 120 | 121 | The following options must be configured in order to make a deployment. 122 | 123 | | Key | Value Information | Type | Required | 124 | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | 125 | | `branch` | This is the branch you wish to deploy to, for example `gh-pages` or `docs`. | `with` | **Yes** | 126 | | `folder` | The folder in your repository that you want to deploy. If your build script compiles into a directory named `build` you'd put it here. If you wish to deploy the root directory you can place a `.` here. You can also utilize absolute file paths by appending `~` to your folder path. | `with` | **Yes** | 127 | 128 | By default the action does not need any token configuration and uses the provided repository scoped GitHub token to make the deployment. If you require more customization you can modify the deployment type using the following options. 129 | 130 | | Key | Value Information | Type | Required | 131 | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | 132 | | `token` | This option defaults to the repository scoped GitHub Token. However if you need more permissions for things such as deploying to another repository, you can add a Personal Access Token (PAT) here. This should be stored in the `secrets / with` menu **as a secret**. We recommend using a service account with the least permissions necessary and recommend when generating a new PAT that you select the least permission scopes necessary. [Learn more about creating and using encrypted secrets here.](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | `with` | **No** | 133 | | `ssh-key` | You can configure the action to deploy using SSH by setting this option to a private SSH key stored **as a secret**. It can also be set to `true` to use an existing SSH client configuration. For more detailed information on how to add your public/private ssh key pair please refer to the [Using a Deploy Key section of this README](https://github.com/JamesIves/github-pages-deploy-action/tree/dev#using-an-ssh-deploy-key-). | `with` | **No** | 134 | 135 | #### Optional Choices 136 | 137 | | Key | Value Information | Type | Required | 138 | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | 139 | | `git-config-name` | Allows you to customize the name that is attached to the git config which is used when pushing the deployment commits. If this is not included it will use the name in the GitHub context, followed by the name of the action. | `with` | **No** | 140 | | `git-config-email` | Allows you to customize the email that is attached to the git config which is used when pushing the deployment commits. If this is not included it will use the email in the GitHub context, followed by a generic noreply GitHub email. You can include an empty string value if you wish to omit this field altogether. | `with` | **No** | 141 | | `repository-name` | Allows you to specify a different repository path so long as you have permissions to push to it. This should be formatted like so: `JamesIves/github-pages-deploy-action`. You'll need to use a PAT in the `token` input for this configuration option to work properly. | `with` | **No** | 142 | | `target-folder` | 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. | `with` | **No** | 143 | | `commit-message` | If you need to customize the commit message for an integration you can do so. | `with` | **No** | 144 | | `clean` | You can use this option to delete files from your deployment destination that no longer exist in your deployment source. One use case is if your project generates hashed files that vary from build to build. Using `clean` will not affect `.git`, `.github`, or `.ssh` directories. This option is turned on by default, and can be toggled off by setting it to `false`. | `with` | **No** | 145 | | `clean-exclude` | If you need to use `clean` but you'd like to preserve certain files or folders you can use this option. This should contain each pattern as a single line in a multiline string. | `with` | **No** | 146 | | `dry-run` | Do not actually push back, but use `--dry-run` on `git push` invocations instead. | `with` | **No** | 147 | | `single-commit` | This option can be toggled to `true` if you'd prefer to have a single commit on the deployment branch instead of maintaining the full history. **Using this option will also cause any existing history to be wiped from the deployment branch**. | `with` | **No** | 148 | | `silent` | Silences the action output preventing it from displaying git messages. | `with` | **No** | 149 | | `workspace` | This should point to where your project lives on the virtual machine. The GitHub Actions environment will set this for you. It is only necessary to set this variable if you're using the node module. | `with` | **No** | 150 | 151 | With the action correctly configured you should see the workflow trigger the deployment under the configured conditions. 152 | 153 | #### Deployment Status 154 | 155 | The action will export an environment variable called `deployment_status` that you can use in your workflow to determine if the deployment was successful or not. You can find an explanation of each status type below. 156 | 157 | | Status | Description | 158 | | --------- | ----------------------------------------------------------------------------------------------- | 159 | | `success` | The `success` status indicates that the action was able to successfully deploy to the branch. | 160 | | `failed` | The `failed` status indicates that the action encountered an error while trying to deploy. | 161 | | `skipped` | The `skipped` status indicates that the action exited early as there was nothing new to deploy. | 162 | 163 | This value is also set as a step output as `deployment-status`. 164 | 165 | --- 166 | 167 | ### Using an SSH Deploy Key 🔑 168 | 169 | If you'd prefer to use an SSH deploy key as opposed to a token you must first generate a new SSH key by running the following terminal command, replacing the email with one connected to your GitHub account. 170 | 171 | ```bash 172 | ssh-keygen -t rsa -m pem -b 4096 -C "youremailhere@example.com" -N "" 173 | ``` 174 | 175 | Once you've generated the key pair you must add the contents of the public key within your repository's [deploy keys menu](https://developer.github.com/v3/guides/managing-deploy-keys/). You can find this option by going to `Settings > Deploy Keys`, you can name the public key whatever you want, but you **do** need to give it write access. Afterwards add the contents of the private key to the `Settings > Secrets` menu as `DEPLOY_KEY`. 176 | 177 | With this configured you can then set the `ssh-key` part of the action to your private key stored as a secret. 178 | 179 | ```yml 180 | - name: Deploy 🚀 181 | uses: JamesIves/github-pages-deploy-action@4.1.6 182 | with: 183 | branch: gh-pages 184 | folder: site 185 | ssh-key: ${{ secrets.DEPLOY_KEY }} 186 | ``` 187 | 188 |
You can view a full example of this here. 189 |

190 | 191 | ```yml 192 | name: Build and Deploy 193 | on: 194 | push: 195 | branches: 196 | - main 197 | jobs: 198 | deploy: 199 | runs-on: ubuntu-latest 200 | steps: 201 | - name: Checkout 🛎️ 202 | uses: actions/checkout@v2.3.1 203 | 204 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 205 | run: | 206 | npm install 207 | npm run build 208 | 209 | - name: Deploy 🚀 210 | uses: JamesIves/github-pages-deploy-action@4.1.6 211 | with: 212 | branch: gh-pages 213 | folder: build 214 | clean: true 215 | clean-exclude: | 216 | special-file.txt 217 | some/*.txt 218 | ssh-key: ${{ secrets.DEPLOY_KEY }} 219 | ``` 220 | 221 |

222 |
223 | 224 | Alternatively if you've already configured the SSH client within a previous step you can set the `ssh-key` option to `true` to allow it to deploy using an existing SSH client. Instead of adjusting the client configuration it will simply switch to using GitHub's SSH endpoints. 225 | 226 | --- 227 | 228 | ### Operating System Support 💿 229 | 230 | This action is primarily developed using [Ubuntu](https://ubuntu.com/). [In your workflow job configuration](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_idruns-on) it's recommended to set the `runs-on` property to `ubuntu-latest`. 231 | 232 | ```yml 233 | jobs: 234 | build-and-deploy: 235 | runs-on: ubuntu-latest 236 | ``` 237 | 238 | If you're using an operating system such as [Windows](https://www.microsoft.com/en-us/windows/) you can workaround this using [artifacts](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts). In your workflow configuration you can utilize the `actions/upload-artifact` and `actions/download-artifact` actions to move your project built on a Windows job to a secondary job that will handle the deployment. 239 | 240 |
You can view an example of this pattern here. 241 |

242 | 243 | ```yml 244 | name: Build and Deploy 245 | on: [push] 246 | jobs: 247 | build: 248 | runs-on: windows-latest # The first job utilizes windows-latest 249 | steps: 250 | - name: Checkout 🛎️ 251 | uses: actions/checkout@v2.3.1 252 | 253 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 254 | run: | 255 | npm install 256 | npm run build 257 | 258 | - name: Upload Artifacts 🔺 # The project is then uploaded as an artifact named 'site'. 259 | uses: actions/upload-artifact@v1 260 | with: 261 | name: site 262 | path: build 263 | 264 | deploy: 265 | needs: [build] # The second job must depend on the first one to complete before running, and uses ubuntu-latest instead of windows. 266 | runs-on: ubuntu-latest 267 | steps: 268 | - name: Checkout 🛎️ 269 | uses: actions/checkout@v2.3.1 270 | 271 | - name: Download Artifacts 🔻 # The built project is downloaded into the 'site' folder. 272 | uses: actions/download-artifact@v1 273 | with: 274 | name: site 275 | 276 | - name: Deploy 🚀 277 | uses: JamesIves/github-pages-deploy-action@4.1.6 278 | with: 279 | branch: gh-pages 280 | folder: 'site' # The deployment folder should match the name of the artifact. Even though our project builds into the 'build' folder the artifact name of 'site' must be placed here. 281 | ``` 282 | 283 |

284 |
285 | 286 | --- 287 | 288 | ### Using a Container 🚢 289 | 290 | If you use a [container](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_idcontainer) in your workflow you may need to run an additional step to install `rsync` as this action depends on it. You can view an example of this below. 291 | 292 | ```yml 293 | - name: Install rsync 📚 294 | run: | 295 | apt-get update && apt-get install -y rsync 296 | 297 | - name: Deploy 🚀 298 | uses: JamesIves/github-pages-deploy-action@4.1.6 299 | ``` 300 | 301 | --- 302 | 303 | ### Additional Build Files 📁 304 | 305 | If you're using a custom domain and require a `CNAME` file, or if you require the use of a `.nojekyll` file, you can safely commit these files directly into deployment branch without them being overridden after each deployment, additionally you can include these files in your deployment folder to update them. If you need to add additional files to the deployment that should be ignored by the build clean-up steps you can utilize the `clean-exclude` option. 306 | 307 |
Click here to view an example of this. 308 |

309 | 310 | ```yml 311 | name: Build and Deploy 312 | on: 313 | push: 314 | branches: 315 | - main 316 | jobs: 317 | deploy: 318 | runs-on: ubuntu-latest 319 | steps: 320 | - name: Checkout 🛎️ 321 | uses: actions/checkout@v2.3.1 322 | 323 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 324 | run: | 325 | npm install 326 | npm run build 327 | 328 | - name: Deploy 🚀 329 | uses: JamesIves/github-pages-deploy-action@4.1.6 330 | with: 331 | branch: gh-pages 332 | folder: build 333 | clean: true 334 | clean-exclude: | 335 | special-file.txt 336 | some/*.txt 337 | ``` 338 | 339 |

340 |
341 | 342 | If you wish to remove these files you must go into the deployment branch directly to remove them. This is to prevent accidental changes in your deployment script from creating breaking changes. 343 | 344 | --- 345 | 346 | ## Support 💖 347 | 348 | This project would not be possible without all of our fantastic [contributors](https://github.com/JamesIves/github-pages-deploy-action/graphs/contributors) and [sponsors](https://github.com/sponsors/JamesIves). If you'd like to support the maintenance and upkeep of this project you can [donate via GitHub Sponsors](https://github.com/sponsors/JamesIves). 349 | 350 | 351 | --------------------------------------------------------------------------------