Skip to content
This repository has been archived by the owner on Mar 31, 2020. It is now read-only.

Commit

Permalink
Merge pull request #1 from primer/alias-rules
Browse files Browse the repository at this point in the history
Support path aliases via rules.json
  • Loading branch information
shawnbot authored Jan 31, 2019
2 parents 7d169a9 + 5c003d3 commit a9ec67c
Show file tree
Hide file tree
Showing 14 changed files with 265 additions and 41 deletions.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
# primer/deploy
This [GitHub Action][github actions] deploys to [Now] and aliases the successful deployment to either:

* The `alias` field in `now.json` if the branch is `master`; or
* A branch-specific URL in the form `{name}-{branch}.now.sh`, where:
* `{name}` is your app's name (in `now.json`, `package.json`, or the name of the directory);
* `{branch}` is the part after `refs/heads/` in the `GITHUB_REF` [environment variable](https://developer.github.com/actions/creating-github-actions/accessing-the-runtime-environment/#environment-variables); and
* Both strings are normalized in the alias to trim any leading non-alphanumeric characters and replace any sequence of non-alphanumeric characters with a single `-`.
This [GitHub Action][github actions] deploys to [Now] and aliases the successful deployment to a predictable URL according to the following conditions:

1. We run `now` without any arguments to get the "root" deployment URL, which is generated by Now.
1. If the branch is `master`, we treat the `alias` field in `now.json` as the production URL and:
1. If there is a `rules.json`:
* `now alias <deployment> <name>.now.sh` to create a fallback URL for path aliases
* `now alias <deployment> <production> -r rules.json` to set up path aliases
1. `now alias <deployment> <production>` to alias the production URL.
1. `now alias <deployment> <name>-<branch>.now.sh` to alias the root deployment to a branch-specific URL.

The app name (`<name>`) and branch (`<branch>`) are both "slugified" to strip invalid characters so that they'll work as URLs. Leading non-word characters are removed, and any sequence of characters that isn't alphanumeric or `-` is replaced with a single `-`. In other words, `@primer/css` becomes `primer-css`, `shawnbot/some_branch` becomes `shawnbot-some-branch`, and so on.

## Status checks
Two [status checks] will be listed for this action in your checks: **deploy** is the action's check, and **deploy/alias** is a [commit status] created by the action that reports the URL and links to it via "Details":
Expand Down Expand Up @@ -33,7 +38,8 @@ action "deploy" {

To avoid racking up failed deployments, we suggest that you place this action after any linting and test actions.


[now]: https://zeit.co/now
[github actions]: https://github.com/features/actions
[commit status]: https://developer.github.com/v3/repos/statuses/
[status checks]: https://help.github.com/articles/about-status-checks/
[status checks]: https://help.github.com/articles/about-status-checks/
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

138 changes: 138 additions & 0 deletions src/__tests__/deploy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const mockedEnv = require('mocked-env')
const readJSON = require('../read-json')
const deploy = require('../deploy')
const now = require('../now')
const commitStatus = require('../commit-status')

jest.mock('../now')
jest.mock('../read-json')
jest.mock('../commit-status')

commitStatus.mockImplementation(() => Promise.resolve({}))

describe('deploy()', () => {
let restoreEnv = () => {}
afterEach(() => {
restoreEnv()
// clear the call data
now.mockReset()
// restore the original behavior
readJSON.mockReset()
})

it('calls now() once when w/o an alias', () => {
mockFiles({
'package.json': {name: 'foo'},
'now.json': {},
'rules.json': null
})

const url = 'foo-123.now.sh'
now.mockImplementation(() => Promise.resolve(url))
mockEnv({GITHUB_REF: ''})

return deploy().then(res => {
expect(now).toHaveBeenCalledTimes(1)
expect(now).toHaveBeenCalledWith()
expect(res).toEqual({name: 'foo', root: url, url})
})
})

it('deploys to a branch alias on branches other than master', () => {
mockFiles({
'package.json': {name: 'foo'},
'now.json': {},
'rules.json': null
})

const url = 'foo-123.now.sh'
const alias = 'foo-bar.now.sh'
now.mockImplementation(() => Promise.resolve(url))
mockEnv({GITHUB_REF: 'refs/heads/bar'})

return deploy().then(res => {
expect(now).toHaveBeenCalledTimes(2)
expect(now).toHaveBeenNthCalledWith(1)
expect(now).toHaveBeenNthCalledWith(2, ['alias', url, alias])
expect(res).toEqual({name: 'foo', root: url, alias, url: alias})
})
})

it('deploys to the "alias" field from now.json on the master branch', () => {
const name = 'hello'
const alias = 'hello.world'
mockFiles({
'package.json': {name},
'now.json': {alias},
'rules.json': null
})

const root = 'hello-123.now.sh'
now.mockImplementation(() => Promise.resolve(root))
mockEnv({GITHUB_REF: 'refs/heads/master'})

return deploy().then(res => {
expect(now).toHaveBeenCalledTimes(2)
expect(now).toHaveBeenNthCalledWith(1)
expect(now).toHaveBeenNthCalledWith(2, ['alias', root, alias])
expect(res).toEqual({name, root, url: alias})
})
})

it('calls now() two times when there is a rules.json, not on master', () => {
const prodAlias = 'foo.now.sh'
mockFiles({
'package.json': {name: 'foo'},
'now.json': {alias: prodAlias},
// exists
'rules.json': {}
})

const url = 'foo-123.now.sh'
const alias = 'foo-bar.now.sh'
now.mockImplementation(() => Promise.resolve(url))
mockEnv({GITHUB_REF: 'refs/heads/bar'})

return deploy().then(res => {
expect(now).toHaveBeenCalledTimes(2)
expect(now).toHaveBeenNthCalledWith(1)
expect(now).toHaveBeenNthCalledWith(2, ['alias', url, alias])
expect(res).toEqual({name: 'foo', root: url, alias, url: alias})
})
})

it('calls now() three times when there is a rules.json, on master', () => {
const prodAlias = 'primer.style'
mockFiles({
'package.json': {name: 'primer-style'},
'now.json': {alias: prodAlias},
// exists
'rules.json': {}
})

const url = 'primer-style-123.now.sh'
const alias = 'primer-style.now.sh'
now.mockImplementation(() => Promise.resolve(url))
mockEnv({GITHUB_REF: 'refs/heads/master'})

return deploy().then(res => {
expect(now).toHaveBeenCalledTimes(3)
expect(now).toHaveBeenNthCalledWith(1)
expect(now).toHaveBeenNthCalledWith(2, ['alias', url, alias])
expect(now).toHaveBeenNthCalledWith(3, ['alias', alias, prodAlias, '-r', 'rules.json'])
expect(res).toEqual({name: 'primer-style', root: url, alias, url: prodAlias})
})
})

function mockEnv(env) {
restoreEnv = mockedEnv(env)
}

function mockFiles(files) {
readJSON.mockImplementation(path => {
if (path in files) {
return files[path]
}
})
}
})
5 changes: 1 addition & 4 deletions src/__tests__/get-alias.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
const mockedEnv = require('mocked-env')
const getAlias = require('../get-alias')

describe('getAlias()', () => {
it('works', () => {
const restore = mockedEnv({GITHUB_REF: 'refs/heads/add-foo'})
expect(getAlias('@primer/css')).toEqual('primer-css-add-foo.now.sh')
restore()
expect(getAlias('@primer/css', 'add-foo')).toEqual('primer-css-add-foo.now.sh')
})
})
6 changes: 6 additions & 0 deletions src/__tests__/get-branch.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@ describe('getBranch()', () => {
expect(getBranch()).toEqual('foo')
restore()
})

it('returns an empty string if GITHUB_REF is unset', () => {
const restore = mockedEnv({GITHUB_REF: undefined})
expect(getBranch()).toEqual('')
restore()
})
})
32 changes: 32 additions & 0 deletions src/__tests__/now.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const execa = require('execa')
const mockedEnv = require('mocked-env')
const now = require('../now')

jest.mock('execa')
execa.mockImplementation(() => Promise.resolve({stderr: '', stdout: ''}))

describe('now()', () => {
let restoreEnv = () => {}
afterEach(() => restoreEnv())

it('throws if process.env.NOW_TOKEN is falsy', () => {
mockEnv({NOW_TOKEN: ''})
expect(() => now()).toThrow()
})

it('calls `npx now --token=$NOW_TOKEN` with no additional args', () => {
mockEnv({NOW_TOKEN: 'xyz'})
now()
expect(execa).lastCalledWith('npx', ['now', '--token=xyz'], {stderr: 'inherit'})
})

it('calls with additional arguments passed as an array', () => {
mockEnv({NOW_TOKEN: 'abc'})
now(['foo', 'bar'])
expect(execa).lastCalledWith('npx', ['now', '--token=abc', 'foo', 'bar'], {stderr: 'inherit'})
})

function mockEnv(env) {
restoreEnv = mockedEnv(env)
}
})
12 changes: 12 additions & 0 deletions src/__tests__/read-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const readJSON = require('../read-json')

describe('readJSON()', () => {
it('reads package.json from the cwd', () => {
const pkg = readJSON('package.json')
expect(pkg instanceof Object).toBe(true)
})

it('returns undefined for non-existent files', () => {
expect(readJSON('x.json')).toBe(undefined)
})
})
63 changes: 50 additions & 13 deletions src/deploy.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
const {dirname} = require('path')
const now = require('./now')
const getAlias = require('./get-alias')
const getBranch = require('./get-branch')
const commitStatus = require('./commit-status')
const getBranchAlias = require('./get-alias')
const now = require('./now')
const readJSON = require('./read-json')

module.exports = function deploy() {
const nowJson = readJSON('now.json') || {}
const packageJson = readJSON('package.json') || {}
const rulesJson = readJSON('rules.json')

module.exports = function deploy(args) {
const nowJson = require('./now-json')
const packageJson = require('./package-json')
const name = nowJson.name || packageJson.name || dirname(process.cwd())
const branch = getBranch(name)

console.log(`[deploy] deploying "${name}" with now...`)
return now(args)
return now()
.then(url => {
console.log(`[deploy] deployed to: ${url}`)
return {name, root: url, url}
if (url) {
// console.log(`[deploy] deployed to: ${url}`)
return {
name,
root: url,
url
}
} else {
throw new Error(`Unable to get deployment URL from now: ${url}`)
}
})
.then(res => {
const {url} = res
const alias = getAlias(name)
if (alias) {
res.url = alias
return now(['alias', ...args, url, alias])
.then(() => commitStatus(alias))
const prodAlias = nowJson.alias
if (branch === 'master' && !rulesJson) {
res.url = prodAlias
return now(['alias', url, prodAlias])
.then(() => commitStatus(prodAlias))
.then(() => res)
}
const branchAlias = getBranchAlias(name, branch)
if (branchAlias) {
res.url = res.alias = branchAlias
return now(['alias', url, branchAlias])
.then(() => commitStatus(branchAlias))
.then(() => {
if (branch === 'master' && rulesJson) {
const {alias} = res
if (!prodAlias) {
console.warn(`[deploy] no alias field in now.json!`)
return res
} else if (prodAlias === alias) {
console.warn(`[deploy] already aliased to production URL: ${alias}; skipping rules.json`)
return res
}
res.url = prodAlias
return now(['alias', alias, prodAlias, '-r', 'rules.json']).then(() => commitStatus(prodAlias))
}
})
.then(() => res)
} else {
return res
}
})
}
2 changes: 0 additions & 2 deletions src/event-json.js

This file was deleted.

4 changes: 1 addition & 3 deletions src/get-alias.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
const getBranch = require('./get-branch')
const slug = require('./slug')

module.exports = function getAlias(name) {
module.exports = function getAlias(name, branch) {
const nameSlug = slug(name)
const branch = getBranch()

if (branch === 'master') {
return `${nameSlug}.now.sh`
Expand Down
4 changes: 0 additions & 4 deletions src/now-json.js

This file was deleted.

3 changes: 2 additions & 1 deletion src/now.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ module.exports = function now(args = []) {
if (!NOW_TOKEN) {
throw new Error(`The NOW_TOKEN env var is required`)
}
return execa('npx', ['now', `--token=${NOW_TOKEN}`, ...args], {stderr: 'inherit'}).then(res => res.stdout)
const nowArgs = ['now', `--token=${NOW_TOKEN}`, ...args]
return execa('npx', nowArgs, {stderr: 'inherit'}).then(res => res.stdout)
}
4 changes: 0 additions & 4 deletions src/package-json.js

This file was deleted.

7 changes: 7 additions & 0 deletions src/read-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const {resolve} = require('path')
const {existsSync} = require('fs')

module.exports = function readJSON(path) {
const resolved = resolve(process.cwd(), path)
return existsSync(resolved) ? require(resolved) : undefined
}

0 comments on commit a9ec67c

Please sign in to comment.