├── .eslintignore ├── .prettierignore ├── doc_assets ├── github-deployment.png └── deploy-url-comment.png ├── src ├── index.d.ts ├── wait.ts ├── inputs.ts └── main.ts ├── .prettierrc.json ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── LICENSE ├── action.yml ├── package.json ├── .gitignore ├── .eslintrc.json ├── README.md ├── __tests__ ├── production-deploy.test.ts └── main.test.ts └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /doc_assets/github-deployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstacruz/actions-netlify/develop/doc_assets/github-deployment.png -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | // Can be removed if https://github.com/netlify/js-client/issues/89 is resolved 2 | declare module 'netlify' 3 | -------------------------------------------------------------------------------- /doc_assets/deploy-url-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstacruz/actions-netlify/develop/doc_assets/deploy-url-comment.png -------------------------------------------------------------------------------- /.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 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/wait.ts: -------------------------------------------------------------------------------- 1 | export async function wait(milliseconds: number): Promise { 2 | return new Promise(resolve => { 3 | if (isNaN(milliseconds)) { 4 | throw new Error('milliseconds not a number') 5 | } 6 | 7 | setTimeout(() => resolve('done!'), milliseconds) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts", "dist"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "build-test" 2 | on: 3 | push: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | jobs: 8 | build: # make sure build/ci work properly 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - run: npm ci 13 | - run: npm run all 14 | test: # make sure the action works on a clean machine without building 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Create a simple Web site 19 | run: mkdir -p deploy_dist && echo -e "
$(date -u)\n$GITHUB_SHA\n$GITHUB_REF
" > deploy_dist/index.html 20 | - uses: ./ 21 | id: deploy-to-netlify 22 | with: 23 | publish-dir: './deploy_dist' 24 | production-branch: master 25 | github-token: ${{ secrets.GITHUB_TOKEN }} 26 | deploy-message: "Deploy from GitHub Actions" 27 | env: 28 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 29 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 30 | - run: "echo 'outputs.deploy-url: ${{ steps.deploy-to-netlify.outputs.deploy-url }}'" 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 Ryo Ota, GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Netlify Actions' 2 | description: 'Netlify deploy from GitHub Actions' 3 | author: 'Ryo Ota' 4 | inputs: 5 | publish-dir: 6 | description: Publish directory 7 | required: true 8 | functions-dir: 9 | description: Functions directory 10 | required: false 11 | deploy-message: 12 | description: Custom deploy message for Netlify 13 | required: false 14 | github-token: 15 | description: GitHub token 16 | required: false 17 | production-branch: 18 | description: Production branch 19 | required: false 20 | production-deploy: 21 | description: Indicate wether to deploy production build 22 | required: false 23 | enable-pull-request-comment: 24 | description: Enable pull request comment 25 | required: false 26 | enable-commit-comment: 27 | description: Enable commit comment 28 | required: false 29 | overwrites-pull-request-comment: 30 | description: Overwrites pull request comment 31 | required: false 32 | netlify-config-path: 33 | description: Path to netlify.toml 34 | required: false 35 | alias: 36 | description: Specifies the prefix for the deployment URL 37 | required: false 38 | outputs: 39 | deploy-url: 40 | description: Deploy URL 41 | runs: 42 | using: 'node12' 43 | main: 'dist/index.js' 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actions-netlify", 3 | "version": "1.1.5", 4 | "private": true, 5 | "description": "GitHub Actions for Netlify", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write **/*.ts", 10 | "format-check": "prettier --check **/*.ts", 11 | "lint": "eslint src/**/*.ts", 12 | "pack": "ncc build", 13 | "test": "jest", 14 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/nwtgck/actions-netlify.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "Netlify" 23 | ], 24 | "author": "Ryo Ota (https://github.com/nwtgck)", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@actions/core": "^1.2.4", 28 | "@actions/github": "^4.0.0", 29 | "netlify": "^4.3.3" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^26.0.3", 33 | "@types/node": "^12.12.47", 34 | "@typescript-eslint/parser": "^3.4.0", 35 | "@zeit/ncc": "^0.22.3", 36 | "eslint": "^5.16.0", 37 | "eslint-plugin-github": "^2.0.0", 38 | "eslint-plugin-jest": "^23.17.1", 39 | "jest": "^26.1.0", 40 | "jest-circus": "^26.1.0", 41 | "js-yaml": "^3.14.0", 42 | "prettier": "^2.0.5", 43 | "ts-jest": "^26.1.1", 44 | "typescript": "^3.9.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/inputs.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | // Why use function rather than raw string? => Inputs should be lazy evaluated. 4 | export interface Inputs { 5 | publishDir(): string 6 | functionsDir(): string | undefined 7 | deployMessage(): string | undefined 8 | productionBranch(): string | undefined 9 | productionDeploy(): boolean 10 | enablePullRequestComment(): boolean 11 | enableCommitComment(): boolean 12 | githubToken(): string 13 | overwritesPullRequestComment(): boolean 14 | netlifyConfigPath(): string | undefined 15 | alias(): string | undefined 16 | } 17 | 18 | export const defaultInputs: Inputs = { 19 | publishDir() { 20 | return core.getInput('publish-dir', {required: true}) 21 | }, 22 | functionsDir() { 23 | return core.getInput('functions-dir') || undefined 24 | }, 25 | deployMessage() { 26 | return core.getInput('deploy-message') || undefined 27 | }, 28 | productionBranch() { 29 | return core.getInput('production-branch') || undefined 30 | }, 31 | productionDeploy(): boolean { 32 | // Default: false 33 | return core.getInput('production-deploy') === 'true' 34 | }, 35 | enablePullRequestComment() { 36 | // Default: true 37 | return (core.getInput('enable-pull-request-comment') || 'true') === 'true' 38 | }, 39 | enableCommitComment() { 40 | // Default: true 41 | return (core.getInput('enable-commit-comment') || 'true') === 'true' 42 | }, 43 | githubToken() { 44 | return core.getInput('github-token') 45 | }, 46 | overwritesPullRequestComment() { 47 | // Default: true 48 | return ( 49 | (core.getInput('overwrites-pull-request-comment') || 'true') === 'true' 50 | ) 51 | }, 52 | netlifyConfigPath() { 53 | return core.getInput('netlify-config-path') || undefined 54 | }, 55 | alias() { 56 | return core.getInput('alias') || undefined 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # TODO: Remove because /dist/typescript should not be generated automatically 2 | /dist/typescript 3 | 4 | # Dependency directory 5 | node_modules 6 | 7 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless/ 89 | 90 | # FuseBox cache 91 | .fusebox/ 92 | 93 | # DynamoDB Local files 94 | .dynamodb/ 95 | 96 | # OS metadata 97 | .DS_Store 98 | Thumbs.db 99 | 100 | # Ignore built ts files 101 | __tests__/runner/* 102 | lib/**/* 103 | 104 | # Intellij 105 | 106 | .idea/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/es6"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "eslint-comments/no-use": "off", 12 | "import/no-namespace": "off", 13 | "no-unused-vars": "off", 14 | "@typescript-eslint/no-unused-vars": "error", 15 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 16 | "@typescript-eslint/no-require-imports": "error", 17 | "@typescript-eslint/array-type": "error", 18 | "@typescript-eslint/await-thenable": "error", 19 | "@typescript-eslint/ban-ts-ignore": "error", 20 | "camelcase": "off", 21 | "@typescript-eslint/camelcase": "error", 22 | "@typescript-eslint/class-name-casing": "error", 23 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 24 | "@typescript-eslint/func-call-spacing": ["error", "never"], 25 | "@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"], 26 | "@typescript-eslint/no-array-constructor": "error", 27 | "@typescript-eslint/no-empty-interface": "error", 28 | "@typescript-eslint/no-explicit-any": "error", 29 | "@typescript-eslint/no-extraneous-class": "error", 30 | "@typescript-eslint/no-for-in-array": "error", 31 | "@typescript-eslint/no-inferrable-types": "error", 32 | "@typescript-eslint/no-misused-new": "error", 33 | "@typescript-eslint/no-namespace": "error", 34 | "@typescript-eslint/no-non-null-assertion": "warn", 35 | "@typescript-eslint/no-object-literal-type-assertion": "error", 36 | "@typescript-eslint/no-unnecessary-qualifier": "error", 37 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 38 | "@typescript-eslint/no-useless-constructor": "error", 39 | "@typescript-eslint/no-var-requires": "error", 40 | "@typescript-eslint/prefer-for-of": "warn", 41 | "@typescript-eslint/prefer-function-type": "warn", 42 | "@typescript-eslint/prefer-includes": "error", 43 | "@typescript-eslint/prefer-interface": "error", 44 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 45 | "@typescript-eslint/promise-function-async": "error", 46 | "@typescript-eslint/require-array-sort-compare": "error", 47 | "@typescript-eslint/restrict-plus-operands": "error", 48 | "semi": "off", 49 | "@typescript-eslint/semi": ["error", "never"], 50 | "@typescript-eslint/type-annotation-spacing": "error", 51 | "@typescript-eslint/unbound-method": "error" 52 | }, 53 | "env": { 54 | "node": true, 55 | "es6": true, 56 | "jest/globals": true 57 | } 58 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actions-netlify 2 | ![build-test](https://github.com/nwtgck/actions-netlify/workflows/build-test/badge.svg) 3 | 4 | GitHub Actions for deploying to Netlify 5 | 6 | 7 | 8 | Deploy URLs are commented on your pull requests and commit comments! 9 | 10 | 11 | 12 | GitHub Deployments are also supported! 13 | 14 | ## Usage 15 | 16 | ```yaml 17 | # .github/workflows/netlify.yml 18 | name: Build and Deploy to Netlify 19 | on: 20 | push: 21 | pull_request: 22 | types: [opened, synchronize] 23 | jobs: 24 | build: 25 | runs-on: ubuntu-18.04 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | # ( Build to ./dist or other directory... ) 30 | 31 | - name: Deploy to Netlify 32 | uses: nwtgck/actions-netlify@v1.1 33 | with: 34 | publish-dir: './dist' 35 | production-branch: master 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | deploy-message: "Deploy from GitHub Actions" 38 | enable-pull-request-comment: false 39 | enable-commit-comment: true 40 | overwrites-pull-request-comment: true 41 | env: 42 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 43 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 44 | timeout-minutes: 1 45 | ``` 46 | 47 | 48 | ### Required inputs and env 49 | - `publish-dir` (e.g. "dist", "_site") 50 | - `NETLIFY_AUTH_TOKEN`: [Personal access tokens](https://app.netlify.com/user/applications#personal-access-tokens) > New access token 51 | - `NETLIFY_SITE_ID`: team page > your site > Settings > Site details > Site information > API ID 52 | - NOTE: API ID is `NETLIFY_SITE_ID`. 53 | 54 | ### Optional inputs 55 | - `production-branch` (e.g. "master") 56 | - `production-deploy`: Deploy as Netlify production deploy (default: false) 57 | - `github-token: ${{ secrets.GITHUB_TOKEN }}` 58 | - `deploy-message` A custom deploy message to see on Netlify deployment (e.g. `${{ github.event.pull_request.title }}`) 59 | - `enable-pull-request-comment: true` Comment on pull request (default: true) 60 | - `enable-commit-comment: true` Comment on GitHub commit (default: true) 61 | - `overwrites-pull-request-comment: true` Overwrites comment on pull request (default: true) 62 | - `netlify-config-path: ./netlify.toml` Path to `netlify.toml` (default: undefined) 63 | - `functions-dir` Netlify functions output directory (default: undefined) 64 | - `alias` Specifies the prefix for the deployment URL (default: Netlify build ID) 65 | - `alias: ${{ github.head_ref }}` replicates the [branch deploy prefix](https://docs.netlify.com/site-deploys/overview/#definitions) 66 | - `alias: deploy-preview-${{ github.event.number }}` replicates the [deploy preview prefix](https://docs.netlify.com/site-deploys/overview/#definitions) 67 | 68 | ### Outputs 69 | - `deploy-url` A deployment URL generated by Netlify 70 | 71 | ## Build on local 72 | 73 | ```bash 74 | npm ci 75 | npm run all 76 | ``` 77 | -------------------------------------------------------------------------------- /__tests__/production-deploy.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import {mocked} from 'ts-jest/utils' 3 | import {defaultInputs} from '../src/inputs' 4 | import {context} from '@actions/github' 5 | import {run} from '../src/main' 6 | 7 | const mockDeploy = jest.fn() 8 | 9 | jest.mock('netlify', () => { 10 | return jest.fn().mockImplementation(() => { 11 | return {deploy: mockDeploy} 12 | }) 13 | }) 14 | jest.mock('../src/inputs') 15 | jest.mock('@actions/github') 16 | 17 | mockDeploy.mockResolvedValue({deploy: {}}) 18 | mocked(defaultInputs.githubToken).mockReturnValue('') // NOTE: empty string means the input is not specified 19 | mocked(defaultInputs.publishDir).mockReturnValue('my-publish-dir') 20 | 21 | process.env.NETLIFY_AUTH_TOKEN = 'dummy-netlify-auth-token' 22 | process.env.NETLIFY_SITE_ID = 'dummy-netlify-site-id' 23 | 24 | describe('Draft deploy', () => { 25 | const expectedSiteId = 'dummy-netlify-site-id' 26 | const expectedDeployFolder = path.resolve(process.cwd(), 'my-publish-dir') 27 | 28 | test('deploy should have draft true when production-deploy input is false', async () => { 29 | mocked(defaultInputs.productionDeploy).mockReturnValue(false) 30 | 31 | await run(defaultInputs) 32 | 33 | expect(mockDeploy).toHaveBeenCalledWith( 34 | expectedSiteId, 35 | expectedDeployFolder, 36 | { 37 | draft: true 38 | } 39 | ) 40 | }) 41 | 42 | test('deploy should have draft false when production-deploy input is true', async () => { 43 | mocked(defaultInputs.productionDeploy).mockReturnValue(true) 44 | 45 | await run(defaultInputs) 46 | 47 | expect(mockDeploy).toHaveBeenCalledWith( 48 | expectedSiteId, 49 | expectedDeployFolder, 50 | { 51 | draft: false 52 | } 53 | ) 54 | }) 55 | 56 | test('deploy should have draft false when production-branch matches context ref', async () => { 57 | mocked(defaultInputs.productionDeploy).mockReturnValue(false) 58 | mocked(defaultInputs.productionBranch).mockReturnValue('master') 59 | 60 | context.ref = 'refs/heads/master' 61 | 62 | await run(defaultInputs) 63 | 64 | expect(mockDeploy).toHaveBeenCalledWith( 65 | expectedSiteId, 66 | expectedDeployFolder, 67 | { 68 | draft: false 69 | } 70 | ) 71 | }) 72 | 73 | test('deploy should have draft true when production-branch doesnt match context ref', async () => { 74 | mocked(defaultInputs.productionDeploy).mockReturnValue(false) 75 | mocked(defaultInputs.productionBranch).mockReturnValue('master') 76 | 77 | context.ref = 'refs/heads/not-master' 78 | 79 | await run(defaultInputs) 80 | 81 | expect(mockDeploy).toHaveBeenCalledWith( 82 | expectedSiteId, 83 | expectedDeployFolder, 84 | { 85 | draft: true 86 | } 87 | ) 88 | }) 89 | 90 | test('deploy should have draft true when production-branch is not defined', async () => { 91 | mocked(defaultInputs.productionDeploy).mockReturnValue(false) 92 | mocked(defaultInputs.productionBranch).mockReturnValue(undefined) 93 | 94 | context.ref = 'refs/heads/master' 95 | 96 | await run(defaultInputs) 97 | 98 | expect(mockDeploy).toHaveBeenCalledWith( 99 | expectedSiteId, 100 | expectedDeployFolder, 101 | { 102 | draft: true 103 | } 104 | ) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | 6 | ## [Unreleased] 7 | 8 | ## [1.1.5] - 2020-06-27 9 | ### Changed 10 | * Update dependencies 11 | 12 | ## [1.1.4] - 2020-06-18 13 | ### Added 14 | * Add "functions-dir" input to deploy Netlify Functions [#191](https://github.com/nwtgck/actions-netlify/pull/191) by [@fmr](https://github.com/fmr) 15 | 16 | ## [1.1.3] - 2020-06-13 17 | ### Added 18 | * Add "production-deploy" input to deploy as Netlify production deploy [#188](https://github.com/nwtgck/actions-netlify/pull/188) by [@gvdp](https://github.com/gvdp) 19 | 20 | ## [1.1.2] - 2020-06-13 21 | ### Added 22 | * Add "alias" input to deploy with alias [#178](https://github.com/nwtgck/actions-netlify/pull/178) by [@rajington](https://github.com/rajington) 23 | 24 | ## [1.1.1] - 2020-05-30 25 | ### Added 26 | * Add "netlify-config-path" input to specify path to `netlify.toml` 27 | * Support GitHub Deployments 28 | 29 | ### Changed 30 | * Update dependencies 31 | 32 | ## [1.1.0] - 2020-05-10 33 | ### Added 34 | * Add "overwrites-pull-request-comment" input 35 | 36 | ### Changed 37 | * Overwrite comment on pull request by default 38 | - You can use `overwrites-pull-request-comment: false` not to overwrite 39 | * Update dependencies 40 | 41 | ## [1.0.13] - 2020-05-09 42 | ### Changed 43 | * Update dependencies 44 | 45 | ### Added 46 | * Add "enable-pull-request-comment" input 47 | * Add "enable-commit-comment" input 48 | 49 | ## [1.0.12] - 2020-04-07 50 | ### Changed 51 | * Update dependencies 52 | 53 | ## [1.0.11] - 2020-04-06 54 | ### Changed 55 | * Update dependencies 56 | 57 | ## [1.0.10] - 2020-04-03 58 | ### Changed 59 | * Update dependencies 60 | 61 | ## [1.0.9] - 2020-04-01 62 | ### Changed 63 | * Update dependencies 64 | 65 | ## [1.0.8] - 2020-03-30 66 | ### Changed 67 | * Update dependencies 68 | 69 | ## [1.0.7] - 2020-03-29 70 | ### Changed 71 | * Update dependencies 72 | 73 | ## [1.0.6] - 2020-03-17 74 | ### Changed 75 | * Update dependencies 76 | 77 | ### Added 78 | * Add `deploy-url` output [#48](https://github.com/nwtgck/actions-netlify/pull/48) by [@kentaro-m](https://github.com/kentaro-m) 79 | 80 | ## [1.0.5] - 2020-03-03 81 | ### Added 82 | * Add `deploy-message` input [#40](https://github.com/nwtgck/actions-netlify/pull/40) by [@South-Paw](https://github.com/South-Paw) 83 | 84 | ## [1.0.4] - 2020-03-02 85 | ### Changed 86 | * Update dependencies 87 | 88 | ## [1.0.3] - 2020-02-28 89 | ### Changed 90 | * Do not error out when no credentials are provided [#33](https://github.com/nwtgck/actions-netlify/pull/33) by [@tiangolo](https://github.com/tiangolo) 91 | * Comment Netlify deploy URL on commit 92 | 93 | ## [1.0.2] - 2020-02-26 94 | ### Changed 95 | * Update dependencies 96 | 97 | ## [1.0.1] - 2020-02-22 98 | ### Changed 99 | * Update dependencies 100 | * Improve files by dependency updates 101 | 102 | ## [1.0.0] - 2020-02-08 103 | ### Changed 104 | * Update dependencies 105 | 106 | ## [0.2.0] - 2020-02-05 107 | ### Added 108 | * Add `production-branch` input 109 | 110 | ### Changed 111 | * Make `github-token` input optional 112 | 113 | ## [0.1.1] - 2020-02-04 114 | ### Changed 115 | * Print deploy URL 116 | 117 | ## 0.1.0 - 2020-02-04 118 | ### Added 119 | * Deploy to Netlify 120 | * Comment on GitHub PR 121 | 122 | [Unreleased]: https://github.com/nwtgck/actions-netlify/compare/v1.1.5...HEAD 123 | [1.1.5]: https://github.com/nwtgck/actions-netlify/compare/v1.1.4...v1.1.5 124 | [1.1.4]: https://github.com/nwtgck/actions-netlify/compare/v1.1.3...v1.1.4 125 | [1.1.3]: https://github.com/nwtgck/actions-netlify/compare/v1.1.2...v1.1.3 126 | [1.1.2]: https://github.com/nwtgck/actions-netlify/compare/v1.1.1...v1.1.2 127 | [1.1.1]: https://github.com/nwtgck/actions-netlify/compare/v1.1.0...v1.1.1 128 | [1.1.0]: https://github.com/nwtgck/actions-netlify/compare/v1.0.13...v1.1.0 129 | [1.0.13]: https://github.com/nwtgck/actions-netlify/compare/v1.0.12...v1.0.13 130 | [1.0.12]: https://github.com/nwtgck/actions-netlify/compare/v1.0.11...v1.0.12 131 | [1.0.11]: https://github.com/nwtgck/actions-netlify/compare/v1.0.10...v1.0.11 132 | [1.0.10]: https://github.com/nwtgck/actions-netlify/compare/v1.0.9...v1.0.10 133 | [1.0.9]: https://github.com/nwtgck/actions-netlify/compare/v1.0.8...v1.0.9 134 | [1.0.8]: https://github.com/nwtgck/actions-netlify/compare/v1.0.7...v1.0.8 135 | [1.0.7]: https://github.com/nwtgck/actions-netlify/compare/v1.0.6...v1.0.7 136 | [1.0.6]: https://github.com/nwtgck/actions-netlify/compare/v1.0.5...v1.0.6 137 | [1.0.5]: https://github.com/nwtgck/actions-netlify/compare/v1.0.4...v1.0.5 138 | [1.0.4]: https://github.com/nwtgck/actions-netlify/compare/v1.0.3...v1.0.4 139 | [1.0.3]: https://github.com/nwtgck/actions-netlify/compare/v1.0.2...v1.0.3 140 | [1.0.2]: https://github.com/nwtgck/actions-netlify/compare/v1.0.1...v1.0.2 141 | [1.0.1]: https://github.com/nwtgck/actions-netlify/compare/v1.0.0...v1.0.1 142 | [1.0.0]: https://github.com/nwtgck/actions-netlify/compare/v0.2.0...v1.0.0 143 | [0.2.0]: https://github.com/nwtgck/actions-netlify/compare/v0.1.1...v0.2.0 144 | [0.1.1]: https://github.com/nwtgck/actions-netlify/compare/v0.1.0...v0.1.1 145 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import {wait} from '../src/wait' 2 | import * as process from 'process' 3 | import * as cp from 'child_process' 4 | import * as path from 'path' 5 | import {defaultInputs} from '../src/inputs' 6 | 7 | /** 8 | * With input 9 | * @param inputName 10 | * @param value 11 | * @param f 12 | */ 13 | export async function withInput( 14 | inputName: string, 15 | value: string, 16 | f: () => void | Promise 17 | ): Promise { 18 | // (from: https://github.com/actions/toolkit/blob/83dd3ef0f1e5bc93c5ab7072e1edf1715a01ba9d/packages/core/src/core.ts#L71) 19 | const envName = `INPUT_${inputName.replace(/ /g, '_').toUpperCase()}` 20 | process.env[envName] = value 21 | await f() 22 | // NOTE: Not sure this is correct deletion 23 | delete process.env[envName] 24 | } 25 | 26 | describe('defaultInputs', () => { 27 | test('publishDir', () => { 28 | withInput('publish-dir', './my_publish_dir', () => { 29 | const publishDir: string = defaultInputs.publishDir() 30 | expect(publishDir).toBe('./my_publish_dir') 31 | }) 32 | }) 33 | 34 | describe('functionsDir', () => { 35 | test('it should be a string when specified', () => { 36 | withInput('functions-dir', './my_functions_dir', () => { 37 | const functionsDir: string | undefined = defaultInputs.functionsDir() 38 | expect(functionsDir).toBe('./my_functions_dir') 39 | }) 40 | }) 41 | 42 | test('it should be undefined when not specified', () => { 43 | const functionsDir: string | undefined = defaultInputs.functionsDir() 44 | expect(functionsDir).toBe(undefined) 45 | }) 46 | }) 47 | 48 | describe('deployMessage', () => { 49 | test('it should be a string when specified', () => { 50 | withInput('deploy-message', 'Deploy with GitHub Actions', () => { 51 | const deployMessage: string | undefined = defaultInputs.deployMessage() 52 | expect(deployMessage).toBe('Deploy with GitHub Actions') 53 | }) 54 | }) 55 | 56 | test('it should be undefined when not specified', () => { 57 | const deployMessage: string | undefined = defaultInputs.deployMessage() 58 | expect(deployMessage).toBe(undefined) 59 | }) 60 | }) 61 | 62 | describe('productionBranch', () => { 63 | test('it should be a string when specified', () => { 64 | withInput('production-branch', 'master', () => { 65 | const productionBranch: 66 | | string 67 | | undefined = defaultInputs.productionBranch() 68 | expect(productionBranch).toBe('master') 69 | }) 70 | }) 71 | 72 | test('it should be undefined when not specified', () => { 73 | const deployMessage: string | undefined = defaultInputs.productionBranch() 74 | expect(deployMessage).toBe(undefined) 75 | }) 76 | }) 77 | 78 | describe('enablePullRequestComment', () => { 79 | test('it should be default value (true) when not specified', () => { 80 | const b: boolean = defaultInputs.enablePullRequestComment() 81 | expect(b).toBe(true) 82 | }) 83 | 84 | test('it should be true when "true" specified', () => { 85 | withInput('enable-pull-request-comment', 'true', () => { 86 | const b: boolean = defaultInputs.enablePullRequestComment() 87 | expect(b).toBe(true) 88 | }) 89 | }) 90 | 91 | test('it should be true when "false" specified', () => { 92 | withInput('enable-pull-request-comment', 'false', () => { 93 | const b: boolean = defaultInputs.enablePullRequestComment() 94 | expect(b).toBe(false) 95 | }) 96 | }) 97 | }) 98 | 99 | describe('enableCommitComment', () => { 100 | test('it should be default value (true) when not specified', () => { 101 | const b: boolean = defaultInputs.enableCommitComment() 102 | expect(b).toBe(true) 103 | }) 104 | 105 | test('it should be true when "true" specified', () => { 106 | withInput('enable-commit-comment', 'true', () => { 107 | const b: boolean = defaultInputs.enableCommitComment() 108 | expect(b).toBe(true) 109 | }) 110 | }) 111 | 112 | test('it should be true when "false" specified', () => { 113 | withInput('enable-commit-comment', 'false', () => { 114 | const b: boolean = defaultInputs.enableCommitComment() 115 | expect(b).toBe(false) 116 | }) 117 | }) 118 | }) 119 | 120 | describe('enableCommitComment', () => { 121 | test('it should be empty string when not specified', () => { 122 | const t: string = defaultInputs.githubToken() 123 | expect(t).toBe('') 124 | }) 125 | 126 | test('it should be a string when specified', () => { 127 | withInput('github-token', 'DUMMY_GITHUB_TOKEN', () => { 128 | const t: string = defaultInputs.githubToken() 129 | expect(t).toBe('DUMMY_GITHUB_TOKEN') 130 | }) 131 | }) 132 | }) 133 | 134 | describe('overwritesPullRequestComment', () => { 135 | test('it should be default value (true) when not specified', () => { 136 | const b: boolean = defaultInputs.overwritesPullRequestComment() 137 | expect(b).toBe(true) 138 | }) 139 | 140 | test('it should be true when "true" specified', () => { 141 | withInput('overwrites-pull-request-comment', 'true', () => { 142 | const b: boolean = defaultInputs.overwritesPullRequestComment() 143 | expect(b).toBe(true) 144 | }) 145 | }) 146 | 147 | test('it should be false when "false" specified', () => { 148 | withInput('overwrites-pull-request-comment', 'false', () => { 149 | const b: boolean = defaultInputs.overwritesPullRequestComment() 150 | expect(b).toBe(false) 151 | }) 152 | }) 153 | }) 154 | 155 | describe('alias', () => { 156 | test('it should be a string when specified', () => { 157 | withInput('alias', 'foo', () => { 158 | const alias: string | undefined = defaultInputs.alias() 159 | expect(alias).toBe('foo') 160 | }) 161 | }) 162 | }) 163 | 164 | describe('production deploy', () => { 165 | test('it should be default value (false) when not specified', () => { 166 | const b: boolean = defaultInputs.productionDeploy() 167 | expect(b).toBe(false) 168 | }) 169 | 170 | test('it should be true when "true" specified', () => { 171 | withInput('production-deploy', 'true', () => { 172 | const b: boolean = defaultInputs.productionDeploy() 173 | expect(b).toBe(true) 174 | }) 175 | }) 176 | 177 | test('it should be false when "false" specified', () => { 178 | withInput('production-deploy', 'false', () => { 179 | const b: boolean = defaultInputs.productionDeploy() 180 | expect(b).toBe(false) 181 | }) 182 | }) 183 | }) 184 | }) 185 | 186 | // Old tests below 187 | 188 | test('throws invalid number', async () => { 189 | const input = parseInt('foo', 10) 190 | await expect(wait(input)).rejects.toThrow('milliseconds not a number') 191 | }) 192 | 193 | test('wait 500 ms', async () => { 194 | const start = new Date() 195 | await wait(500) 196 | const end = new Date() 197 | var delta = Math.abs(end.getTime() - start.getTime()) 198 | expect(delta).toBeGreaterThan(450) 199 | }) 200 | 201 | // shows how the runner will run a javascript action with env / stdout protocol 202 | test('test runs', () => { 203 | if (false) { 204 | process.env['INPUT_MILLISECONDS'] = '500' 205 | const ip = path.join(__dirname, '..', 'lib', 'main.js') 206 | const options: cp.ExecSyncOptions = { 207 | env: process.env 208 | } 209 | console.log(cp.execSync(`node ${ip}`, options).toString()) 210 | } 211 | }) 212 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {context, getOctokit} from '@actions/github' 3 | import type {GitHub} from '@actions/github/lib/utils' 4 | import {ReposCreateDeploymentResponseData} from '@octokit/types/dist-types/generated/Endpoints' 5 | import {OctokitResponse} from '@octokit/types/dist-types/OctokitResponse' 6 | import NetlifyAPI from 'netlify' 7 | import * as path from 'path' 8 | import {defaultInputs, Inputs} from './inputs' 9 | 10 | const commentIdentifierString = 11 | '' 12 | 13 | async function findIssueComment( 14 | githubClient: InstanceType 15 | ): Promise { 16 | const listCommentsRes = await githubClient.issues.listComments({ 17 | owner: context.issue.owner, 18 | repo: context.issue.repo, 19 | // eslint-disable-next-line @typescript-eslint/camelcase 20 | issue_number: context.issue.number 21 | }) 22 | 23 | const comments = listCommentsRes.data 24 | for (const comment of comments) { 25 | // If comment contains the comment identifier 26 | if (comment.body.includes(commentIdentifierString)) { 27 | return comment.id 28 | } 29 | } 30 | return undefined 31 | } 32 | 33 | async function createGitHubDeployment( 34 | githubClient: InstanceType, 35 | environmentUrl: string, 36 | environment: string 37 | ): Promise { 38 | const {ref} = context 39 | const deployment = await githubClient.repos.createDeployment({ 40 | // eslint-disable-next-line @typescript-eslint/camelcase 41 | auto_merge: false, 42 | owner: context.repo.owner, 43 | repo: context.repo.repo, 44 | ref, 45 | environment, 46 | // eslint-disable-next-line @typescript-eslint/camelcase 47 | required_contexts: [] 48 | }) 49 | await githubClient.repos.createDeploymentStatus({ 50 | state: 'success', 51 | // eslint-disable-next-line @typescript-eslint/camelcase 52 | environment_url: environmentUrl, 53 | owner: context.repo.owner, 54 | repo: context.repo.repo, 55 | // eslint-disable-next-line @typescript-eslint/camelcase 56 | deployment_id: (deployment as OctokitResponse< 57 | ReposCreateDeploymentResponseData 58 | >).data.id 59 | }) 60 | } 61 | 62 | export async function run(inputs: Inputs): Promise { 63 | try { 64 | const netlifyAuthToken = process.env.NETLIFY_AUTH_TOKEN 65 | const siteId = process.env.NETLIFY_SITE_ID 66 | // NOTE: Non-collaborators PRs don't pass GitHub secrets to GitHub Actions. 67 | if (!(netlifyAuthToken && siteId)) { 68 | process.stdout.write('Netlify credentials not provided, not deployable') 69 | return 70 | } 71 | const dir = inputs.publishDir() 72 | const functionsDir: string | undefined = inputs.functionsDir() 73 | const deployMessage: string | undefined = inputs.deployMessage() 74 | const productionBranch: string | undefined = inputs.productionBranch() 75 | const enablePullRequestComment: boolean = inputs.enablePullRequestComment() 76 | const enableCommitComment: boolean = inputs.enableCommitComment() 77 | const overwritesPullRequestComment: boolean = inputs.overwritesPullRequestComment() 78 | const netlifyConfigPath: string | undefined = inputs.netlifyConfigPath() 79 | const alias: string | undefined = inputs.alias() 80 | 81 | const branchMatchesProduction: boolean = 82 | !!productionBranch && context.ref === `refs/heads/${productionBranch}` 83 | const productionDeploy: boolean = 84 | branchMatchesProduction || inputs.productionDeploy() 85 | // Create Netlify API client 86 | const netlifyClient = new NetlifyAPI(netlifyAuthToken) 87 | // Resolve publish directory 88 | const deployFolder = path.resolve(process.cwd(), dir) 89 | // Resolve functions directory 90 | const functionsFolder = 91 | functionsDir && path.resolve(process.cwd(), functionsDir) 92 | // Deploy to Netlify 93 | const deploy = await netlifyClient.deploy(siteId, deployFolder, { 94 | draft: !productionDeploy, 95 | message: deployMessage, 96 | configPath: netlifyConfigPath, 97 | branch: alias, 98 | fnDir: functionsFolder 99 | }) 100 | // Create a message 101 | const message = productionDeploy 102 | ? `🎉 Published on ${deploy.deploy.ssl_url} as production\n🚀 Deployed on ${deploy.deploy.deploy_ssl_url}` 103 | : `🚀 Deployed on ${deploy.deploy.deploy_ssl_url}` 104 | // Print the URL 105 | process.stdout.write(`${message}\n`) 106 | 107 | // Set the deploy URL to outputs for GitHub Actions 108 | const deployUrl = productionDeploy 109 | ? deploy.deploy.ssl_url 110 | : deploy.deploy.deploy_ssl_url 111 | core.setOutput('deploy-url', deployUrl) 112 | 113 | // Get GitHub token 114 | const githubToken = inputs.githubToken() 115 | if (githubToken !== '') { 116 | const markdownComment = `${commentIdentifierString}\n${message}` 117 | 118 | // Create GitHub client 119 | const githubClient = getOctokit(githubToken) 120 | 121 | if (enableCommitComment) { 122 | const commitCommentParams = { 123 | owner: context.repo.owner, 124 | repo: context.repo.repo, 125 | // eslint-disable-next-line @typescript-eslint/camelcase 126 | commit_sha: context.sha, 127 | body: markdownComment 128 | } 129 | // TODO: Remove try 130 | // NOTE: try-catch is experimentally used because commit message may not be done in some conditions. 131 | try { 132 | // Comment to the commit 133 | await githubClient.repos.createCommitComment(commitCommentParams) 134 | } catch (err) { 135 | // eslint-disable-next-line no-console 136 | console.error(err, JSON.stringify(commitCommentParams, null, 2)) 137 | } 138 | } 139 | 140 | if (context.issue.number === undefined) { 141 | try { 142 | const environment = productionDeploy ? 'production' : 'commit' 143 | // Create GitHub Deployment 144 | await createGitHubDeployment(githubClient, deployUrl, environment) 145 | } catch (err) { 146 | // eslint-disable-next-line no-console 147 | console.error(err) 148 | } 149 | } 150 | 151 | // If it is a pull request and enable comment on pull request 152 | if (context.issue.number !== undefined) { 153 | if (enablePullRequestComment) { 154 | let commentId: number | undefined = undefined 155 | if (overwritesPullRequestComment) { 156 | // Find issue comment 157 | commentId = await findIssueComment(githubClient) 158 | } 159 | 160 | // NOTE: if not overwrite, commentId is always undefined 161 | if (commentId !== undefined) { 162 | // Update comment of the deploy URL 163 | await githubClient.issues.updateComment({ 164 | owner: context.issue.owner, 165 | repo: context.issue.repo, 166 | // eslint-disable-next-line @typescript-eslint/camelcase 167 | comment_id: commentId, 168 | body: markdownComment 169 | }) 170 | } else { 171 | // Comment the deploy URL 172 | await githubClient.issues.createComment({ 173 | // eslint-disable-next-line @typescript-eslint/camelcase 174 | issue_number: context.issue.number, 175 | owner: context.repo.owner, 176 | repo: context.repo.repo, 177 | body: markdownComment 178 | }) 179 | } 180 | } 181 | 182 | try { 183 | const environmentUrl = deploy.deploy.deploy_ssl_url 184 | // Create GitHub Deployment 185 | await createGitHubDeployment( 186 | githubClient, 187 | environmentUrl, 188 | 'pull request' 189 | ) 190 | } catch (err) { 191 | // eslint-disable-next-line no-console 192 | console.error(err) 193 | } 194 | } 195 | } 196 | } catch (error) { 197 | core.setFailed(error.message) 198 | } 199 | } 200 | 201 | run(defaultInputs) 202 | --------------------------------------------------------------------------------