Test current code base as an integration test for PRs and pushes (#505)

* Add a build step to create lib and node_modules artifact

* Run integration test with built dist and current SHA as base

For pull requests, the github.sha is the sha of the merge to the
target branch, not the head of the PR. Special case that.

* Use v2 checkout, and DRY_RUN for the integration test.

I also made the branches more generic, as there are now more of them.

* Fix #536, don't push at all on dryRun

Also add tests for dryRun and singleCommit and generateBranch
code flows.

* Try to fix dryRun on new remote branches, refactor fetch

* Try to fix dryRun, only fetch if origin branch exists

* Refactor worktree setup to include branch generation and setup for singleCommit

This is a continuation of the no-checkout work, and sadly suggested pretty
intensive changes.

* Set up git config to fix tests, also make debugging easier

* Add matrix for existing and non-existing branch

* Add matrix for singleCommit and not

* Drop GITHUB_TOKEN, add DRY_RUN to action.yml

* When deploying existing branch, add a modifcation and deploy again

* Force branch checkout to work in redeployment scenarios

* Make singleCommit easier to see in job descriptions

* Review comments

* Add a test-only property to action to test code paths with remote branch.

* Introduce TestFlag enum to signal different test scenarios to unit tests

* Fix util.test.ts
This commit is contained in:
Axel Hecht 2020-12-14 18:30:22 +01:00 committed by GitHub
parent ac885860a8
commit 4e40ddd3f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 570 additions and 225 deletions

View File

@ -55,7 +55,8 @@
"@typescript-eslint/type-annotation-spacing": "error", "@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error", "@typescript-eslint/unbound-method": "error",
"no-console": "off", "no-console": "off",
"no-shadow": ["error", { "builtinGlobals": false, "hoist": "all", "allow": ["Status"] }] "no-shadow": "off", // replaced by ts-eslint rule below
"@typescript-eslint/no-shadow": "error"
}, },
"env": { "env": {
"node": true, "node": true,

View File

@ -2,8 +2,8 @@ name: unit-tests
on: on:
pull_request: pull_request:
branches: branches:
- dev - 'dev*'
- releases/v3 - 'releases/v*'
push: push:
branches: branches:
- dev - dev
@ -14,11 +14,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v1 uses: actions/checkout@v2
- uses: actions/setup-node@v1.4.4 - uses: actions/setup-node@v1.4.4
with: with:
node-version: '10.15.1' node-version: 'v12.18.4'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Install Yarn - name: Install Yarn
@ -34,3 +34,80 @@ jobs:
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- uses: actions/setup-node@v1.4.4
with:
node-version: 'v12.18.4'
registry-url: 'https://registry.npmjs.org'
- name: Install Yarn
run: npm install -g yarn
- name: Build lib
run: |
yarn install
yarn build
- name: Rebuild production node_modules
run: |
yarn install --production
ls node_modules
- name: artifact
uses: actions/upload-artifact@v2
with:
name: dist
path: |
lib
node_modules
integration:
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
branch: ["gh-pages", "no-pages"]
commit: ["singleCommit", "add commits"]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
persist-credentials: false
- uses: actions/setup-node@v1.4.4
with:
node-version: 'v12.18.4'
registry-url: 'https://registry.npmjs.org'
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: dist
- name: Deploy
uses: ./
with:
FOLDER: integration
BRANCH: ${{ matrix.branch }}
SINGLE_COMMIT: ${{ matrix.commit == 'singleCommit' }}
DRY_RUN: true
- name: Tweak content to publish to existing branch
if: ${{ matrix.branch == 'gh-pages' }}
run: |
echo "<!-- just sayin -->" >> integration/index.html
- name: Deploy with modifications to existing branch
uses: ./
if: ${{ matrix.branch == 'gh-pages' }}
with:
FOLDER: integration
BRANCH: ${{ matrix.branch }}
SINGLE_COMMIT: ${{ matrix.commit == 'singleCommit' }}
DRY_RUN: true

View File

@ -1,2 +1 @@
process.env.UNIT_TEST = 'true'
process.env.ACTIONS_STEP_DEBUG = 'false' process.env.ACTIONS_STEP_DEBUG = 'false'

View File

@ -4,9 +4,9 @@ process.env['INPUT_FOLDER'] = 'build'
process.env['GITHUB_SHA'] = '123' process.env['GITHUB_SHA'] = '123'
import {mkdirP, rmRF} from '@actions/io' import {mkdirP, rmRF} from '@actions/io'
import {action, Status} from '../src/constants' import {action, Status, TestFlag} from '../src/constants'
import {execute} from '../src/execute' import {execute} from '../src/execute'
import {deploy, generateBranch, init} from '../src/git' import {deploy, init} from '../src/git'
import fs from 'fs' import fs from 'fs'
const originalAction = JSON.stringify(action) const originalAction = JSON.stringify(action)
@ -46,11 +46,11 @@ describe('git', () => {
token: '123', token: '123',
branch: 'branch', branch: 'branch',
folder: '.', folder: '.',
isTest: true,
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
} },
isTest: TestFlag.HAS_CHANGED_FILES
}) })
try { try {
@ -63,49 +63,6 @@ describe('git', () => {
}) })
}) })
describe('generateBranch', () => {
it('should execute five commands', async () => {
Object.assign(action, {
silent: false,
token: '123',
branch: 'branch',
folder: '.',
pusher: {
name: 'asd',
email: 'as@cat'
}
})
await generateBranch(action)
expect(execute).toBeCalledTimes(5)
})
it('should catch when a function throws an error', async () => {
;(execute as jest.Mock).mockImplementationOnce(() => {
throw new Error('Mocked throw')
})
Object.assign(action, {
silent: false,
token: '123',
branch: 'branch',
folder: '.',
pusher: {
name: 'asd',
email: 'as@cat'
}
})
try {
await generateBranch(action)
} catch (error) {
expect(error.message).toBe(
'There was an error creating the deployment branch: Mocked throw ❌'
)
}
})
})
describe('deploy', () => { describe('deploy', () => {
it('should execute commands', async () => { it('should execute commands', async () => {
Object.assign(action, { Object.assign(action, {
@ -116,24 +73,19 @@ describe('git', () => {
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
} },
isTest: TestFlag.HAS_CHANGED_FILES
}) })
const response = await deploy(action) const response = await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(11)
expect(execute).toHaveBeenNthCalledWith(
9,
expect.not.stringContaining('--dry-run'),
expect.anything(),
false
)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(response).toBe(Status.SUCCESS) expect(response).toBe(Status.SUCCESS)
}) })
it('should push with --dry-run', async () => { it('should not push when asked to dryRun', async () => {
Object.assign(action, { Object.assign(action, {
silent: false, silent: false,
dryRun: true, dryRun: true,
@ -143,19 +95,14 @@ describe('git', () => {
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
} },
isTest: TestFlag.HAS_CHANGED_FILES
}) })
const response = await deploy(action) const response = await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(10)
expect(execute).toHaveBeenNthCalledWith(
9,
expect.stringContaining('--dry-run'),
expect.anything(),
false
)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(response).toBe(Status.SUCCESS) expect(response).toBe(Status.SUCCESS)
}) })
@ -172,13 +119,61 @@ describe('git', () => {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
}, },
clean: true clean: true,
isTest: TestFlag.HAS_CHANGED_FILES
}) })
await deploy(action) await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(16) expect(execute).toBeCalledTimes(10)
expect(rmRF).toBeCalledTimes(1)
})
it('should execute commands with single commit toggled and existing branch', async () => {
Object.assign(action, {
silent: false,
folder: 'other',
folderPath: 'other',
branch: 'branch',
token: '123',
singleCommit: true,
pusher: {
name: 'asd',
email: 'as@cat'
},
clean: true,
isTest: TestFlag.HAS_CHANGED_FILES | TestFlag.HAS_REMOTE_BRANCH
})
await deploy(action)
// Includes the call to generateWorktree
expect(execute).toBeCalledTimes(9)
expect(rmRF).toBeCalledTimes(1)
})
it('should execute commands with single commit and dryRun toggled', async () => {
Object.assign(action, {
silent: false,
folder: 'other',
folderPath: 'other',
branch: 'branch',
gitHubToken: '123',
singleCommit: true,
dryRun: true,
pusher: {
name: 'asd',
email: 'as@cat'
},
clean: true,
isTest: TestFlag.HAS_CHANGED_FILES
})
await deploy(action)
// Includes the call to generateWorktree
expect(execute).toBeCalledTimes(9)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
}) })
@ -193,7 +188,8 @@ describe('git', () => {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
}, },
clean: true clean: true,
isTest: TestFlag.HAS_CHANGED_FILES
}) })
fs.createWriteStream('assets/.nojekyll') fs.createWriteStream('assets/.nojekyll')
@ -201,13 +197,13 @@ describe('git', () => {
const response = await deploy(action) const response = await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(11)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(response).toBe(Status.SUCCESS) expect(response).toBe(Status.SUCCESS)
}) })
it('should execute commands with clean options, ommits sha commit message', async () => { it('should execute commands with clean options, commits sha commit message', async () => {
process.env.GITHUB_SHA = '' process.env.GITHUB_SHA = ''
Object.assign(action, { Object.assign(action, {
silent: false, silent: false,
@ -221,13 +217,14 @@ describe('git', () => {
}, },
clean: true, clean: true,
cleanExclude: '["cat", "montezuma"]', cleanExclude: '["cat", "montezuma"]',
workspace: 'other' workspace: 'other',
isTest: TestFlag.NONE
}) })
await deploy(action) await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(8)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
}) })
@ -243,13 +240,14 @@ describe('git', () => {
email: 'as@cat' email: 'as@cat'
}, },
clean: true, clean: true,
cleanExclude: ['cat', 'montezuma'] cleanExclude: ['cat', 'montezuma'],
isTest: TestFlag.NONE
}) })
await deploy(action) await deploy(action)
// Includes the call to generateBranch // Includes the call to generateWorktree
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(8)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
}) })
@ -263,13 +261,13 @@ describe('git', () => {
clean: true, clean: true,
targetFolder: 'new_folder', targetFolder: 'new_folder',
commitMessage: 'Hello!', commitMessage: 'Hello!',
isTest: true, isTest: TestFlag.NONE,
cleanExclude: '["cat, "montezuma"]' // There is a syntax errror in the string. cleanExclude: '["cat, "montezuma"]' // There is a syntax errror in the string.
}) })
await deploy(action) await deploy(action)
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(8)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(mkdirP).toBeCalledTimes(1) expect(mkdirP).toBeCalledTimes(1)
}) })
@ -284,11 +282,11 @@ describe('git', () => {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
}, },
isTest: false // Setting this env variable to false means there will never be anything to commit and the action will exit early. isTest: TestFlag.NONE // Setting this flag to None means there will never be anything to commit and the action will exit early.
}) })
const response = await deploy(action) const response = await deploy(action)
expect(execute).toBeCalledTimes(10) expect(execute).toBeCalledTimes(8)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(response).toBe(Status.SKIPPED) expect(response).toBe(Status.SKIPPED)
}) })
@ -306,7 +304,8 @@ describe('git', () => {
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
} },
isTest: TestFlag.HAS_CHANGED_FILES
}) })
try { try {

View File

@ -5,7 +5,7 @@ process.env['GITHUB_SHA'] = '123'
process.env['INPUT_DEBUG'] = 'debug' process.env['INPUT_DEBUG'] = 'debug'
import '../src/main' import '../src/main'
import {action} from '../src/constants' import {action, TestFlag} from '../src/constants'
import run from '../src/lib' import run from '../src/lib'
import {execute} from '../src/execute' import {execute} from '../src/execute'
import {rmRF} from '@actions/io' import {rmRF} from '@actions/io'
@ -44,11 +44,11 @@ describe('main', () => {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
}, },
isTest: false, isTest: TestFlag.NONE,
debug: true debug: true
}) })
await run(action) await run(action)
expect(execute).toBeCalledTimes(12) expect(execute).toBeCalledTimes(10)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(exportVariable).toBeCalledTimes(1) expect(exportVariable).toBeCalledTimes(1)
}) })
@ -62,10 +62,11 @@ describe('main', () => {
pusher: { pusher: {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
} },
isTest: TestFlag.HAS_CHANGED_FILES
}) })
await run(action) await run(action)
expect(execute).toBeCalledTimes(12) expect(execute).toBeCalledTimes(13)
expect(rmRF).toBeCalledTimes(1) expect(rmRF).toBeCalledTimes(1)
expect(exportVariable).toBeCalledTimes(1) expect(exportVariable).toBeCalledTimes(1)
}) })
@ -80,7 +81,7 @@ describe('main', () => {
name: 'asd', name: 'asd',
email: 'as@cat' email: 'as@cat'
}, },
isTest: true isTest: TestFlag.HAS_CHANGED_FILES
}) })
await run(action) await run(action)
expect(execute).toBeCalledTimes(0) expect(execute).toBeCalledTimes(0)

View File

@ -1,4 +1,4 @@
import {ActionInterface} from '../src/constants' import {ActionInterface, TestFlag} from '../src/constants'
import { import {
isNullOrUndefined, isNullOrUndefined,
generateTokenType, generateTokenType,
@ -39,7 +39,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: null, token: null,
ssh: true, ssh: true,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateTokenType(action)).toEqual('SSH Deploy Key') expect(generateTokenType(action)).toEqual('SSH Deploy Key')
}) })
@ -51,7 +52,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: '123', token: '123',
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateTokenType(action)).toEqual('Deploy Token') expect(generateTokenType(action)).toEqual('Deploy Token')
}) })
@ -63,7 +65,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: null, token: null,
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateTokenType(action)).toEqual('…') expect(generateTokenType(action)).toEqual('…')
}) })
@ -78,7 +81,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: null, token: null,
ssh: true, ssh: true,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateRepositoryPath(action)).toEqual( expect(generateRepositoryPath(action)).toEqual(
'git@github.com:JamesIves/github-pages-deploy-action' 'git@github.com:JamesIves/github-pages-deploy-action'
@ -93,7 +97,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: '123', token: '123',
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateRepositoryPath(action)).toEqual( expect(generateRepositoryPath(action)).toEqual(
'https://x-access-token:123@github.com/JamesIves/github-pages-deploy-action.git' 'https://x-access-token:123@github.com/JamesIves/github-pages-deploy-action.git'
@ -110,7 +115,8 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: 'anothersecret123333', token: 'anothersecret123333',
silent: false silent: false,
isTest: TestFlag.NONE
} }
const string = `This is an error message! It contains ${action.token} and ${action.repositoryPath} and ${action.token} again!` const string = `This is an error message! It contains ${action.token} and ${action.repositoryPath} and ${action.token} again!`
@ -128,7 +134,8 @@ describe('util', () => {
workspace: 'src/', workspace: 'src/',
folder: 'build', folder: 'build',
token: 'anothersecret123333', token: 'anothersecret123333',
silent: false silent: false,
isTest: TestFlag.NONE
} }
process.env['RUNNER_DEBUG'] = '1' process.env['RUNNER_DEBUG'] = '1'
@ -149,7 +156,8 @@ describe('util', () => {
folder: 'build', folder: 'build',
token: null, token: null,
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateFolderPath(action)).toEqual('src/build') expect(generateFolderPath(action)).toEqual('src/build')
}) })
@ -161,7 +169,8 @@ describe('util', () => {
folder: '/home/user/repo/build', folder: '/home/user/repo/build',
token: null, token: null,
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateFolderPath(action)).toEqual('/home/user/repo/build') expect(generateFolderPath(action)).toEqual('/home/user/repo/build')
}) })
@ -173,7 +182,8 @@ describe('util', () => {
folder: './build', folder: './build',
token: null, token: null,
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
expect(generateFolderPath(action)).toEqual('src/build') expect(generateFolderPath(action)).toEqual('src/build')
}) })
@ -185,7 +195,8 @@ describe('util', () => {
folder: '~/repo/build', folder: '~/repo/build',
token: null, token: null,
ssh: null, ssh: null,
silent: false silent: false,
isTest: TestFlag.NONE
} }
process.env.HOME = '/home/user' process.env.HOME = '/home/user'
expect(generateFolderPath(action)).toEqual('/home/user/repo/build') expect(generateFolderPath(action)).toEqual('/home/user/repo/build')
@ -199,7 +210,8 @@ describe('util', () => {
repositoryPath: undefined, repositoryPath: undefined,
branch: 'branch', branch: 'branch',
folder: 'build', folder: 'build',
workspace: 'src/' workspace: 'src/',
isTest: TestFlag.NONE
} }
try { try {
@ -218,7 +230,8 @@ describe('util', () => {
token: '', token: '',
branch: 'branch', branch: 'branch',
folder: 'build', folder: 'build',
workspace: 'src/' workspace: 'src/',
isTest: TestFlag.NONE
} }
try { try {
@ -237,7 +250,8 @@ describe('util', () => {
token: '123', token: '123',
branch: '', branch: '',
folder: 'build', folder: 'build',
workspace: 'src/' workspace: 'src/',
isTest: TestFlag.NONE
} }
try { try {
@ -254,7 +268,8 @@ describe('util', () => {
token: '123', token: '123',
branch: 'branch', branch: 'branch',
folder: '', folder: '',
workspace: 'src/' workspace: 'src/',
isTest: TestFlag.NONE
} }
try { try {
@ -273,7 +288,8 @@ describe('util', () => {
token: '123', token: '123',
branch: 'branch', branch: 'branch',
folder: 'notARealFolder', folder: 'notARealFolder',
workspace: '.' workspace: '.',
isTest: TestFlag.NONE
} }
try { try {

View File

@ -0,0 +1,35 @@
import {TestFlag} from '../src/constants'
import {execute} from '../src/execute'
import {generateWorktree} from '../src/worktree'
jest.mock('../src/execute', () => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
execute: jest.fn()
}))
describe('generateWorktree', () => {
it('should catch when a function throws an error', async () => {
;(execute as jest.Mock).mockImplementationOnce(() => {
throw new Error('Mocked throw')
})
try {
await generateWorktree(
{
workspace: 'somewhere',
singleCommit: false,
branch: 'gh-pages',
folder: '',
silent: true,
isTest: TestFlag.HAS_CHANGED_FILES
},
'worktree',
true
)
} catch (error) {
expect(error.message).toBe(
'There was an error creating the worktree: Mocked throw ❌'
)
}
})
})

195
__tests__/worktree.test.ts Normal file
View File

@ -0,0 +1,195 @@
import {rmRF} from '@actions/io'
import {TestFlag} from '../src/constants'
import {generateWorktree} from '../src/worktree'
import {execute} from '../src/execute'
import fs from 'fs'
import os from 'os'
import path from 'path'
jest.mock('@actions/core', () => ({
setFailed: jest.fn(),
getInput: jest.fn(),
isDebug: jest.fn(),
info: jest.fn()
}))
/*
Test generateWorktree against a known git repository.
The upstream repository `origin` is set up once for the test suite,
and for each test run, a new clone is created.
See workstree.error.test.ts for testing mocked errors from git.*/
describe('generateWorktree', () => {
let tempdir: string | null = null
let clonedir: string | null = null
beforeAll(async () => {
// Set up origin repository
const silent = true
tempdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-deploy-'))
const origin = path.join(tempdir, 'origin')
await execute('git init origin', tempdir, silent)
await execute('git config user.email "you@example.com"', origin, silent)
await execute('git config user.name "Jane Doe"', origin, silent)
await execute('git checkout -b main', origin, silent)
fs.writeFileSync(path.join(origin, 'f1'), 'hello world\n')
await execute('git add .', origin, silent)
await execute('git commit -mc0', origin, silent)
fs.writeFileSync(path.join(origin, 'f1'), 'hello world\nand planets\n')
await execute('git add .', origin, silent)
await execute('git commit -mc1', origin, silent)
await execute('git checkout --orphan gh-pages', origin, silent)
await execute('git reset --hard', origin, silent)
await fs.promises.writeFile(path.join(origin, 'gh1'), 'pages content\n')
await execute('git add .', origin, silent)
await execute('git commit -mgh0', origin, silent)
await fs.promises.writeFile(
path.join(origin, 'gh1'),
'pages content\ngoes on\n'
)
await execute('git add .', origin, silent)
await execute('git commit -mgh1', origin, silent)
})
beforeEach(async () => {
// Clone origin to our workspace for each test
const silent = true
clonedir = path.join(tempdir as string, 'clone')
await execute('git init clone', tempdir as string, silent)
await execute('git config user.email "you@example.com"', clonedir, silent)
await execute('git config user.name "Jane Doe"', clonedir, silent)
await execute(
`git remote add origin ${path.join(tempdir as string, 'origin')}`,
clonedir,
silent
)
await execute('git fetch --depth=1 origin main', clonedir, silent)
await execute('git checkout main', clonedir, silent)
})
afterEach(async () => {
// Tear down workspace
await rmRF(clonedir as string)
})
afterAll(async () => {
// Tear down origin repository
if (tempdir) {
await rmRF(tempdir)
// console.log(tempdir)
}
})
describe('with existing branch and new commits', () => {
it('should check out the latest commit', async () => {
const workspace = clonedir as string
await generateWorktree(
{
workspace,
singleCommit: false,
branch: 'gh-pages',
folder: '',
silent: true,
isTest: TestFlag.NONE
},
'worktree',
true
)
const dirEntries = await fs.promises.readdir(
path.join(workspace, 'worktree')
)
expect(dirEntries.sort((a, b) => a.localeCompare(b))).toEqual([
'.git',
'gh1'
])
const commitMessages = await execute(
'git log --format=%s',
path.join(workspace, 'worktree'),
true
)
expect(commitMessages).toBe('gh1')
})
})
describe('with missing branch and new commits', () => {
it('should create initial commit', async () => {
const workspace = clonedir as string
await generateWorktree(
{
workspace,
singleCommit: false,
branch: 'no-pages',
folder: '',
silent: true,
isTest: TestFlag.NONE
},
'worktree',
false
)
const dirEntries = await fs.promises.readdir(
path.join(workspace, 'worktree')
)
expect(dirEntries).toEqual(['.git'])
const commitMessages = await execute(
'git log --format=%s',
path.join(workspace, 'worktree'),
true
)
expect(commitMessages).toBe('Initial no-pages commit')
})
})
describe('with existing branch and singleCommit', () => {
it('should check out the latest commit', async () => {
const workspace = clonedir as string
await generateWorktree(
{
workspace,
singleCommit: true,
branch: 'gh-pages',
folder: '',
silent: true,
isTest: TestFlag.NONE
},
'worktree',
true
)
const dirEntries = await fs.promises.readdir(
path.join(workspace, 'worktree')
)
expect(dirEntries.sort((a, b) => a.localeCompare(b))).toEqual([
'.git',
'gh1'
])
expect(async () => {
await execute(
'git log --format=%s',
path.join(workspace, 'worktree'),
true
)
}).rejects.toThrow()
})
})
describe('with missing branch and singleCommit', () => {
it('should create initial commit', async () => {
const workspace = clonedir as string
await generateWorktree(
{
workspace,
singleCommit: true,
branch: 'no-pages',
folder: '',
silent: true,
isTest: TestFlag.NONE
},
'worktree',
false
)
const dirEntries = await fs.promises.readdir(
path.join(workspace, 'worktree')
)
expect(dirEntries).toEqual(['.git'])
expect(async () => {
await execute(
'git log --format=%s',
path.join(workspace, 'worktree'),
true
)
}).rejects.toThrow()
})
})
})

View File

@ -50,6 +50,10 @@ inputs:
description: "If you need to use CLEAN but you would like to preserve certain files or folders you can use this option. This should be formatted as an array but stored as a string." description: "If you need to use CLEAN but you would like to preserve certain files or folders you can use this option. This should be formatted as an array but stored as a string."
required: false required: false
DRY_RUN:
description: "Do not actually push back, but use `--dry-run` on `git push` invocations insead."
required: false
GIT_CONFIG_NAME: GIT_CONFIG_NAME:
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." 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."
required: false required: false

View File

@ -4,6 +4,13 @@ import {isNullOrUndefined} from './util'
const {pusher, repository} = github.context.payload const {pusher, repository} = github.context.payload
/* Flags to signal different scenarios to test cases */
export enum TestFlag {
NONE = 0,
HAS_CHANGED_FILES = 1 << 1, // Assume changes to commit
HAS_REMOTE_BRANCH = 1 << 2 // Assume remote repository has existing commits
}
/* For more information please refer to the README: https://github.com/JamesIves/github-pages-deploy-action */ /* For more information please refer to the README: https://github.com/JamesIves/github-pages-deploy-action */
export interface ActionInterface { export interface ActionInterface {
/** The branch that the action should deploy to. */ /** The branch that the action should deploy to. */
@ -22,8 +29,8 @@ export interface ActionInterface {
folder: string folder: string
/** The auto generated folder path. */ /** The auto generated folder path. */
folderPath?: string folderPath?: string
/** Determines if the action is running in test mode or not. */ /** Determines test scenarios the action is running in. */
isTest?: boolean | null isTest: TestFlag
/** The git config name. */ /** The git config name. */
name?: string name?: string
/** The repository path, for example JamesIves/github-pages-deploy-action. */ /** The repository path, for example JamesIves/github-pages-deploy-action. */
@ -62,6 +69,7 @@ export interface NodeActionInterface {
ssh?: boolean | null ssh?: boolean | null
/** The folder where your deployment project lives. */ /** The folder where your deployment project lives. */
workspace: string workspace: string
isTest: TestFlag
} }
/* Required action data that gets initialized when running within the GitHub Actions environment. */ /* Required action data that gets initialized when running within the GitHub Actions environment. */
@ -76,9 +84,7 @@ export const action: ActionInterface = {
? getInput('CLEAN').toLowerCase() === 'true' ? getInput('CLEAN').toLowerCase() === 'true'
: false, : false,
cleanExclude: getInput('CLEAN_EXCLUDE'), cleanExclude: getInput('CLEAN_EXCLUDE'),
isTest: process.env.UNIT_TEST isTest: TestFlag.NONE,
? process.env.UNIT_TEST.toLowerCase() === 'true'
: false,
email: !isNullOrUndefined(getInput('GIT_CONFIG_EMAIL')) email: !isNullOrUndefined(getInput('GIT_CONFIG_EMAIL'))
? getInput('GIT_CONFIG_EMAIL') ? getInput('GIT_CONFIG_EMAIL')
: pusher && pusher.email : pusher && pusher.email
@ -115,7 +121,7 @@ export const action: ActionInterface = {
/** Types for the required action parameters. */ /** Types for the required action parameters. */
export type RequiredActionParameters = Pick< export type RequiredActionParameters = Pick<
ActionInterface, ActionInterface,
'token' | 'ssh' | 'branch' | 'folder' 'token' | 'ssh' | 'branch' | 'folder' | 'isTest'
> >
/** Status codes for the action. */ /** Status codes for the action. */

View File

@ -1,8 +1,9 @@
import {info} from '@actions/core' import {info} from '@actions/core'
import {mkdirP, rmRF} from '@actions/io' import {mkdirP, rmRF} from '@actions/io'
import fs from 'fs' import fs from 'fs'
import {ActionInterface, Status} from './constants' import {ActionInterface, Status, TestFlag} from './constants'
import {execute} from './execute' import {execute} from './execute'
import {generateWorktree} from './worktree'
import {isNullOrUndefined, suppressSensitiveInformation} from './util' import {isNullOrUndefined, suppressSensitiveInformation} from './util'
/* Initializes git in the workspace. */ /* Initializes git in the workspace. */
@ -33,41 +34,6 @@ export async function init(action: ActionInterface): Promise<void | Error> {
} }
} }
/* Generates the branch if it doesn't exist on the remote. */
export async function generateBranch(action: ActionInterface): Promise<void> {
try {
info(`Creating the ${action.branch} branch…`)
await execute(
`git checkout --orphan ${action.branch}`,
action.workspace,
action.silent
)
await execute(`git reset --hard`, action.workspace, action.silent)
await execute(
`git commit --no-verify --allow-empty -m "Initial ${action.branch} commit"`,
action.workspace,
action.silent
)
const dry = action.dryRun ? '--dry-run ' : ''
await execute(
`git push --force ${dry}${action.repositoryPath} ${action.branch}`,
action.workspace,
action.silent
)
await execute(`git fetch`, action.workspace, action.silent)
info(`Created the ${action.branch} branch… 🔧`)
} catch (error) {
throw new Error(
`There was an error creating the deployment branch: ${suppressSensitiveInformation(
error.message,
action
)} `
)
}
}
/* Runs the necessary steps to make the deployment. */ /* Runs the necessary steps to make the deployment. */
export async function deploy(action: ActionInterface): Promise<Status> { export async function deploy(action: ActionInterface): Promise<Status> {
const temporaryDeploymentDirectory = const temporaryDeploymentDirectory =
@ -75,7 +41,6 @@ export async function deploy(action: ActionInterface): Promise<Status> {
const temporaryDeploymentBranch = `github-pages-deploy-action/${Math.random() const temporaryDeploymentBranch = `github-pages-deploy-action/${Math.random()
.toString(36) .toString(36)
.substr(2, 9)}` .substr(2, 9)}`
const dry = action.dryRun ? '--dry-run ' : ''
info('Starting to commit changes…') info('Starting to commit changes…')
@ -86,31 +51,16 @@ export async function deploy(action: ActionInterface): Promise<Status> {
process.env.GITHUB_SHA ? ` from @ ${process.env.GITHUB_SHA}` : '' process.env.GITHUB_SHA ? ` from @ ${process.env.GITHUB_SHA}` : ''
} 🚀` } 🚀`
/* // Checks to see if the remote exists prior to deploying.
Checks to see if the remote exists prior to deploying. const branchExists =
If the branch doesn't exist it gets created here as an orphan. action.isTest & TestFlag.HAS_REMOTE_BRANCH ||
*/ (await execute(
const branchExists = await execute(
`git ls-remote --heads ${action.repositoryPath} ${action.branch} | wc -l`, `git ls-remote --heads ${action.repositoryPath} ${action.branch} | wc -l`,
action.workspace, action.workspace,
action.silent action.silent
) ))
if (!branchExists && !action.isTest) { await generateWorktree(action, temporaryDeploymentDirectory, branchExists)
await generateBranch(action)
} else {
await execute(
`git fetch --no-recurse-submodules --depth=1 origin ${action.branch}`,
action.workspace,
action.silent
)
}
await execute(
`git worktree add --checkout ${temporaryDeploymentDirectory} origin/${action.branch}`,
action.workspace,
action.silent
)
// Ensures that items that need to be excluded from the clean job get parsed. // Ensures that items that need to be excluded from the clean job get parsed.
let excludes = '' let excludes = ''
@ -166,13 +116,23 @@ export async function deploy(action: ActionInterface): Promise<Status> {
action.silent action.silent
) )
const hasFilesToCommit = await execute( // Use git status to check if we have something to commit.
`git status --porcelain`, // Special case is singleCommit with existing history, when
// we're really interested if the diff against the upstream branch
// changed.
const checkGitStatus =
branchExists && action.singleCommit
? `git diff origin/${action.branch}`
: `git status --porcelain`
const hasFilesToCommit =
action.isTest & TestFlag.HAS_CHANGED_FILES ||
(await execute(
checkGitStatus,
`${action.workspace}/${temporaryDeploymentDirectory}`, `${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent action.silent
) ))
if (!hasFilesToCommit && !action.isTest) { if (!hasFilesToCommit) {
return Status.SKIPPED return Status.SKIPPED
} }
@ -192,49 +152,16 @@ export async function deploy(action: ActionInterface): Promise<Status> {
`${action.workspace}/${temporaryDeploymentDirectory}`, `${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent action.silent
) )
if (!action.dryRun) {
await execute( await execute(
`git push --force ${dry}${action.repositoryPath} ${temporaryDeploymentBranch}:${action.branch}`, `git push --force ${action.repositoryPath} ${temporaryDeploymentBranch}:${action.branch}`,
`${action.workspace}/${temporaryDeploymentDirectory}`, `${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent action.silent
) )
}
info(`Changes committed to the ${action.branch} branch… 📦`) info(`Changes committed to the ${action.branch} branch… 📦`)
if (action.singleCommit) {
await execute(
`git fetch ${action.repositoryPath}`,
action.workspace,
action.silent
)
await execute(
`git checkout --orphan ${action.branch}-temp`,
`${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent
)
await execute(
`git add --all .`,
`${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent
)
await execute(
`git commit -m "${commitMessage}" --quiet --no-verify`,
`${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent
)
await execute(
`git branch -M ${action.branch}-temp ${action.branch}`,
`${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent
)
await execute(
`git push origin ${action.branch} ${dry}--force`,
`${action.workspace}/${temporaryDeploymentDirectory}`,
action.silent
)
info('Cleared git history… 🚿')
}
return Status.SUCCESS return Status.SUCCESS
} catch (error) { } catch (error) {
throw new Error( throw new Error(

85
src/worktree.ts Normal file
View File

@ -0,0 +1,85 @@
import {info} from '@actions/core'
import {ActionInterface} from './constants'
import {execute} from './execute'
import {suppressSensitiveInformation} from './util'
export class GitCheckout {
orphan = false
commitish?: string | null = null
branch: string
constructor(branch: string) {
this.branch = branch
}
toString(): string {
return [
'git',
'checkout',
this.orphan ? '--orphan' : '-B',
this.branch,
this.commitish || ''
].join(' ')
}
}
/* Generate the worktree and set initial content if it exists */
export async function generateWorktree(
action: ActionInterface,
worktreedir: string,
branchExists: boolean
): Promise<void> {
try {
info('Creating worktree…')
if (branchExists) {
await execute(
`git fetch --no-recurse-submodules --depth=1 origin ${action.branch}`,
action.workspace,
action.silent
)
}
await execute(
`git worktree add --no-checkout --detach ${worktreedir}`,
action.workspace,
action.silent
)
const checkout = new GitCheckout(action.branch)
if (branchExists) {
// There's existing data on the branch to check out
checkout.commitish = `origin/${action.branch}`
}
if (!branchExists || action.singleCommit) {
// Create a new history if we don't have the branch, or if we want to reset it
checkout.orphan = true
}
await execute(
checkout.toString(),
`${action.workspace}/${worktreedir}`,
action.silent
)
if (!branchExists) {
info(`Created the ${action.branch} branch… 🔧`)
// Our index is in HEAD state, reset
await execute(
'git reset --hard',
`${action.workspace}/${worktreedir}`,
action.silent
)
if (!action.singleCommit) {
// New history isn't singleCommit, create empty initial commit
await execute(
`git commit --no-verify --allow-empty -m "Initial ${action.branch} commit"`,
`${action.workspace}/${worktreedir}`,
action.silent
)
}
}
} catch (error) {
throw new Error(
`There was an error creating the worktree: ${suppressSensitiveInformation(
error.message,
action
)} `
)
}
}