├── .gitignore ├── index.js ├── .prettierrc.js ├── test ├── support │ ├── server.js │ └── setupTests.js └── action.spec.js ├── jest.config.js ├── .github └── workflows │ ├── test.yml │ └── codeql-analysis.yml ├── package.json ├── LICENSE ├── action.yml ├── README.md └── action.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | .idea/ -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { run } = require('./action'); 2 | 3 | run(); 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | tabs: true, 5 | semi: true, 6 | singleQuote: true, 7 | }; 8 | -------------------------------------------------------------------------------- /test/support/server.js: -------------------------------------------------------------------------------- 1 | const { setupServer } = require('msw/node'); 2 | const { rest } = require('msw'); 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | exports.server = setupServer(); 6 | 7 | exports.rest = rest; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['./test/support/setupTests.js'], 3 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', './action.js'], 4 | coveragePathIgnorePatterns: ['/node_modules/', '/test/', './index.js'], 5 | moduleNameMapper: { 6 | '^axios$': require.resolve('axios'), 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/support/setupTests.js: -------------------------------------------------------------------------------- 1 | const { server } = require('./server.js'); 2 | 3 | // Establish API mocking before all tests. 4 | beforeAll(() => server.listen()); 5 | 6 | // Reset any request handlers that we may add during the tests, 7 | // so they don't affect other tests. 8 | afterEach(() => server.resetHandlers()); 9 | 10 | // Clean up after the tests are finished. 11 | afterAll(() => server.close()); 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | pr: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: npm-cache 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/.npm 24 | key: ${{ runner.os }}-node-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} 25 | restore-keys: | 26 | ${{ runner.os }}-node-${{ env.cache-name }} 27 | ${{ runner.os }}-node 28 | 29 | - name: install 30 | run: npm ci 31 | 32 | - name: test 33 | run: npm run test:ci:coverage 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wait-for-vercel-preview", 3 | "version": "1.3.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/patrickedqvist/wait-for-vercel-preview.git" 9 | }, 10 | "scripts": { 11 | "test": "jest --watch", 12 | "test:ci": "jest --ci", 13 | "test:ci:coverage": "jest --ci --collect-coverage --silent", 14 | "build": "ncc build index.js -o dist" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/patrickedqvist/wait-for-vercel-preview/issues" 21 | }, 22 | "homepage": "https://github.com/patrickedqvist/wait-for-vercel-preview#readme", 23 | "dependencies": { 24 | "@actions/core": "^1.10.0", 25 | "@actions/github": "^5.1.1", 26 | "@vercel/ncc": "^0.36.0", 27 | "axios": "^1.2.4", 28 | "set-cookie-parser": "^2.5.1" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.4.0", 32 | "deepmerge": "^4.2.2", 33 | "jest": "^29.4.0", 34 | "msw": "^1.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Patrick Edqvist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Wait for Vercel Preview' 2 | description: 'Wait for Vercel Deploy Preview to complete. Requires to be run on pull_request or push.' 3 | branding: 4 | icon: 'clock' 5 | color: 'blue' 6 | inputs: 7 | token: 8 | description: 'The Github Secret' 9 | required: true 10 | max_timeout: 11 | description: 'The max time to run the action' 12 | required: false 13 | environment: 14 | description: 'The name of the environment that was deployed to (e.g., staging or production)' 15 | required: false 16 | allow_inactive: 17 | description: 'Use the most recent inactive deployment (previously deployed preview) associated with the pull request if no new deployment is available.' 18 | required: false 19 | default: 'false' 20 | check_interval: 21 | description: 'How often (in seconds) should we make the HTTP request checking to see if the deployment is available?' 22 | default: '2' 23 | required: false 24 | vercel_password: 25 | description: 'Vercel password protection secret' 26 | required: false 27 | vercel_protection_bypass_header: 28 | description: 'Vercel protection bypass for automation' 29 | required: false 30 | path: 31 | description: 'The path to check. Defaults to the index of the domain' 32 | default: '/' 33 | required: false 34 | 35 | outputs: 36 | url: 37 | description: 'The fully qualified deploy preview URL' 38 | vercel_jwt: 39 | description: 'If the deployment is using password protection, this will be a JWT. Otherwise, outputs an empty string' 40 | runs: 41 | using: 'node20' 42 | main: 'dist/index.js' 43 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '21 21 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wait for Vercel Preview — A GitHub Action ⏱ 2 | 3 | Do you have other Github actions (Lighthouse, Cypress, etc) that depend on the Vercel Preview URL? This action will wait until the url is available before running the next task. 4 | 5 | Please note that this action is supposed to be run on the `pull_request` or `push` events. 6 | 7 | ## Inputs 8 | 9 | ### `token` (Required) 10 | 11 | The github secret `${{ secrets.GITHUB_TOKEN }}` 12 | 13 | ### `environment` 14 | 15 | Optional — The name of the environment that was deployed to (e.g., staging or production) 16 | 17 | ### `max_timeout` 18 | 19 | Optional — The amount of time to spend waiting on Vercel. Defaults to `60` seconds 20 | 21 | ### `allow_inactive` 22 | 23 | Optional - Use the most recent inactive deployment (previously deployed preview) associated with the pull request if 24 | no new deployment is available. Defaults to `false`. 25 | 26 | ### `check_interval` 27 | 28 | Optional - How often (in seconds) should we make the HTTP request checking to see if the deployment is available? Defaults to `2` seconds. 29 | 30 | ### `vercel_password` 31 | 32 | Optional - The [password](https://vercel.com/docs/concepts/projects/overview#password-protection) for the deployment 33 | 34 | ### `vercel_protection_bypass_header` 35 | 36 | Optional - The [header](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) to bypass protection for automation 37 | 38 | ### `path` 39 | 40 | Optional - The URL that tests should run against (eg. `path: "https://vercel.com"`). 41 | 42 | ## Outputs 43 | 44 | ### `url` 45 | 46 | The vercel deploy preview url that was deployed. 47 | 48 | ### `vercel_jwt` 49 | 50 | If accessing a password protected site, the JWT from the login event. This can be passed on to e2e tests, for instance. 51 | 52 | ## Example usage 53 | 54 | Basic Usage 55 | 56 | ```yaml 57 | steps: 58 | - name: Waiting for 200 from the Vercel Preview 59 | uses: patrickedqvist/wait-for-vercel-preview@v1.3.1 60 | id: waitFor200 61 | with: 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | max_timeout: 60 64 | # access preview url 65 | - run: echo ${{steps.waitFor200.outputs.url}} 66 | ``` 67 | 68 | ## Building 69 | 70 | The Action is bundled via [ncc](https://github.com/vercel/ncc). See [this discussion](https://github.com/actions/hello-world-javascript-action/issues/12) for more information. 71 | 72 | ```sh 73 | npm run build 74 | # outputs the build to dist/index.js 75 | ``` 76 | 77 | ## Tests 78 | 79 | Unit tests with [Jest](https://jestjs.io/) and [Mock Service Worker](https://mswjs.io/) 80 | 81 | ``` 82 | npm test 83 | ``` 84 | -------------------------------------------------------------------------------- /action.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Dependencies are compiled using https://github.com/vercel/ncc 3 | const core = require('@actions/core'); 4 | const github = require('@actions/github'); 5 | const axios = require('axios'); 6 | const setCookieParser = require('set-cookie-parser'); 7 | 8 | const calculateIterations = (maxTimeoutSec, checkIntervalInMilliseconds) => 9 | Math.floor(maxTimeoutSec / (checkIntervalInMilliseconds / 1000)); 10 | 11 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 12 | 13 | const waitForUrl = async ({ 14 | url, 15 | maxTimeout, 16 | checkIntervalInMilliseconds, 17 | vercelPassword, 18 | protectionBypassHeader, 19 | path, 20 | }) => { 21 | const iterations = calculateIterations( 22 | maxTimeout, 23 | checkIntervalInMilliseconds 24 | ); 25 | 26 | for (let i = 0; i < iterations; i++) { 27 | try { 28 | let headers = {}; 29 | 30 | if (vercelPassword) { 31 | const jwt = await getPassword({ 32 | url, 33 | vercelPassword, 34 | }); 35 | 36 | headers = { 37 | Cookie: `_vercel_jwt=${jwt}`, 38 | }; 39 | 40 | core.setOutput('vercel_jwt', jwt); 41 | } 42 | 43 | if (protectionBypassHeader) { 44 | headers = { 45 | 'x-vercel-protection-bypass': protectionBypassHeader 46 | }; 47 | } 48 | 49 | let checkUri = new URL(path, url); 50 | 51 | await axios.get(checkUri.toString(), { 52 | headers, 53 | }); 54 | console.log('Received success status code'); 55 | return; 56 | } catch (e) { 57 | // https://axios-http.com/docs/handling_errors 58 | if (e.response) { 59 | console.log( 60 | `GET status: ${e.response.status}. Attempt ${i} of ${iterations}` 61 | ); 62 | } else if (e.request) { 63 | console.log( 64 | `GET error. A request was made, but no response was received. Attempt ${i} of ${iterations}` 65 | ); 66 | console.log(e.message); 67 | } else { 68 | console.log(e); 69 | } 70 | 71 | await wait(checkIntervalInMilliseconds); 72 | } 73 | } 74 | 75 | core.setFailed(`Timeout reached: Unable to connect to ${url}`); 76 | }; 77 | 78 | /** 79 | * See https://vercel.com/docs/errors#errors/bypassing-password-protection-programmatically 80 | * @param {{url: string; vercelPassword: string }} options vercel password options 81 | * @returns {Promise} 82 | */ 83 | const getPassword = async ({ url, vercelPassword }) => { 84 | console.log('requesting vercel JWT'); 85 | 86 | const data = new URLSearchParams(); 87 | data.append('_vercel_password', vercelPassword); 88 | 89 | const response = await axios({ 90 | url, 91 | method: 'post', 92 | data: data.toString(), 93 | headers: { 94 | 'content-type': 'application/x-www-form-urlencoded', 95 | }, 96 | maxRedirects: 0, 97 | validateStatus: (status) => { 98 | // Vercel returns 303 with the _vercel_jwt 99 | return status >= 200 && status < 307; 100 | }, 101 | }); 102 | 103 | const setCookieHeader = response.headers['set-cookie']; 104 | 105 | if (!setCookieHeader) { 106 | throw new Error('no vercel JWT in response'); 107 | } 108 | 109 | const cookies = setCookieParser(setCookieHeader); 110 | 111 | const vercelJwtCookie = cookies.find( 112 | (cookie) => cookie.name === '_vercel_jwt' 113 | ); 114 | 115 | if (!vercelJwtCookie || !vercelJwtCookie.value) { 116 | throw new Error('no vercel JWT in response'); 117 | } 118 | 119 | console.log('received vercel JWT'); 120 | 121 | return vercelJwtCookie.value; 122 | }; 123 | 124 | const waitForStatus = async ({ 125 | token, 126 | owner, 127 | repo, 128 | deployment_id, 129 | maxTimeout, 130 | allowInactive, 131 | checkIntervalInMilliseconds, 132 | }) => { 133 | const octokit = new github.getOctokit(token); 134 | const iterations = calculateIterations( 135 | maxTimeout, 136 | checkIntervalInMilliseconds 137 | ); 138 | 139 | for (let i = 0; i < iterations; i++) { 140 | try { 141 | const statuses = await octokit.rest.repos.listDeploymentStatuses({ 142 | owner, 143 | repo, 144 | deployment_id, 145 | }); 146 | 147 | const status = statuses.data.length > 0 && statuses.data[0]; 148 | 149 | if (!status) { 150 | throw new StatusError('No status was available'); 151 | } 152 | 153 | if (status && allowInactive === true && status.state === 'inactive') { 154 | return status; 155 | } 156 | 157 | if (status && status.state !== 'success') { 158 | throw new StatusError('No status with state "success" was available'); 159 | } 160 | 161 | if (status && status.state === 'success') { 162 | return status; 163 | } 164 | 165 | throw new StatusError('Unknown status error'); 166 | } catch (e) { 167 | console.log( 168 | `Deployment unavailable or not successful, retrying (attempt ${ 169 | i + 1 170 | } / ${iterations})` 171 | ); 172 | if (e instanceof StatusError) { 173 | if (e.message.includes('No status with state "success"')) { 174 | // TODO: does anything actually need to be logged in this case? 175 | } else { 176 | console.log(e.message); 177 | } 178 | } else { 179 | console.log(e); 180 | } 181 | await wait(checkIntervalInMilliseconds); 182 | } 183 | } 184 | core.setFailed( 185 | `Timeout reached: Unable to wait for an deployment to be successful` 186 | ); 187 | }; 188 | 189 | class StatusError extends Error { 190 | constructor(message) { 191 | super(message); 192 | } 193 | } 194 | 195 | /** 196 | * Waits until the github API returns a deployment for 197 | * a given actor. 198 | * 199 | * Accounts for race conditions where this action starts 200 | * before the actor's action has started. 201 | * 202 | * @returns 203 | */ 204 | const waitForDeploymentToStart = async ({ 205 | octokit, 206 | owner, 207 | repo, 208 | sha, 209 | environment, 210 | actorName = 'vercel[bot]', 211 | maxTimeout = 20, 212 | checkIntervalInMilliseconds = 2000, 213 | }) => { 214 | const iterations = calculateIterations( 215 | maxTimeout, 216 | checkIntervalInMilliseconds 217 | ); 218 | 219 | for (let i = 0; i < iterations; i++) { 220 | try { 221 | const deployments = await octokit.rest.repos.listDeployments({ 222 | owner, 223 | repo, 224 | sha, 225 | environment, 226 | }); 227 | 228 | const deployment = 229 | deployments.data.length > 0 && 230 | deployments.data.find((deployment) => { 231 | return deployment.creator.login === actorName; 232 | }); 233 | 234 | if (deployment) { 235 | return deployment; 236 | } 237 | 238 | console.log( 239 | `Could not find any deployments for actor ${actorName}, retrying (attempt ${ 240 | i + 1 241 | } / ${iterations})` 242 | ); 243 | } catch(e) { 244 | console.log( 245 | `Error while fetching deployments, retrying (attempt ${ 246 | i + 1 247 | } / ${iterations})` 248 | ); 249 | 250 | console.error(e) 251 | } 252 | 253 | await wait(checkIntervalInMilliseconds); 254 | } 255 | 256 | return null; 257 | }; 258 | 259 | async function getShaForPullRequest({ octokit, owner, repo, number }) { 260 | const PR_NUMBER = github.context.payload.pull_request.number; 261 | 262 | if (!PR_NUMBER) { 263 | core.setFailed('No pull request number was found'); 264 | return; 265 | } 266 | 267 | // Get information about the pull request 268 | const currentPR = await octokit.rest.pulls.get({ 269 | owner, 270 | repo, 271 | pull_number: PR_NUMBER, 272 | }); 273 | 274 | if (currentPR.status !== 200) { 275 | core.setFailed('Could not get information about the current pull request'); 276 | return; 277 | } 278 | 279 | // Get Ref from pull request 280 | const prSHA = currentPR.data.head.sha; 281 | 282 | return prSHA; 283 | } 284 | 285 | const run = async () => { 286 | try { 287 | // Inputs 288 | const GITHUB_TOKEN = core.getInput('token', { required: true }); 289 | const VERCEL_PASSWORD = core.getInput('vercel_password'); 290 | const VERCEL_PROTECTION_BYPASS_HEADER = core.getInput('vercel_protection_bypass_header'); 291 | const ENVIRONMENT = core.getInput('environment'); 292 | const MAX_TIMEOUT = Number(core.getInput('max_timeout')) || 60; 293 | const ALLOW_INACTIVE = core.getBooleanInput('allow_inactive'); 294 | const PATH = core.getInput('path') || '/'; 295 | const CHECK_INTERVAL_IN_MS = 296 | (Number(core.getInput('check_interval')) || 2) * 1000; 297 | 298 | // Fail if we have don't have a github token 299 | if (!GITHUB_TOKEN) { 300 | core.setFailed('Required field `token` was not provided'); 301 | } 302 | 303 | const octokit = github.getOctokit(GITHUB_TOKEN); 304 | 305 | const context = github.context; 306 | const owner = context.repo.owner; 307 | const repo = context.repo.repo; 308 | 309 | /** 310 | * @type {string} 311 | */ 312 | let sha; 313 | 314 | if (github.context.payload && github.context.payload.pull_request) { 315 | sha = await getShaForPullRequest({ 316 | octokit, 317 | owner, 318 | repo, 319 | number: github.context.payload.pull_request.number, 320 | }); 321 | } else if (github.context.sha) { 322 | sha = github.context.sha; 323 | } 324 | 325 | if (!sha) { 326 | core.setFailed('Unable to determine SHA. Exiting...'); 327 | return; 328 | } 329 | 330 | // Get deployments associated with the pull request. 331 | const deployment = await waitForDeploymentToStart({ 332 | octokit, 333 | owner, 334 | repo, 335 | sha: sha, 336 | environment: ENVIRONMENT, 337 | actorName: 'vercel[bot]', 338 | maxTimeout: MAX_TIMEOUT, 339 | checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, 340 | }); 341 | 342 | if (!deployment) { 343 | core.setFailed('no vercel deployment found, exiting...'); 344 | return; 345 | } 346 | 347 | const status = await waitForStatus({ 348 | owner, 349 | repo, 350 | deployment_id: deployment.id, 351 | token: GITHUB_TOKEN, 352 | maxTimeout: MAX_TIMEOUT, 353 | allowInactive: ALLOW_INACTIVE, 354 | checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, 355 | }); 356 | 357 | // Get target url 358 | const targetUrl = status.target_url; 359 | 360 | if (!targetUrl) { 361 | core.setFailed(`no target_url found in the status check`); 362 | return; 363 | } 364 | 365 | console.log('target url »', targetUrl); 366 | 367 | // Set output 368 | core.setOutput('url', targetUrl); 369 | 370 | // Wait for url to respond with a success 371 | console.log(`Waiting for a status code 200 from: ${targetUrl}`); 372 | 373 | await waitForUrl({ 374 | url: targetUrl, 375 | maxTimeout: MAX_TIMEOUT, 376 | checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, 377 | vercelPassword: VERCEL_PASSWORD, 378 | protectionBypassHeader: VERCEL_PROTECTION_BYPASS_HEADER, 379 | path: PATH, 380 | }); 381 | } catch (error) { 382 | core.setFailed(error.message); 383 | } 384 | }; 385 | 386 | exports.run = run; 387 | -------------------------------------------------------------------------------- /test/action.spec.js: -------------------------------------------------------------------------------- 1 | /// @ts-check 2 | 3 | const { run } = require('../action'); 4 | const core = require('@actions/core'); 5 | const github = require('@actions/github'); 6 | const { server, rest } = require('./support/server'); 7 | const deepmerge = require('deepmerge'); 8 | 9 | jest.setTimeout(20000); 10 | 11 | jest.mock('@actions/core', () => { 12 | return { 13 | getInput: jest.fn(), 14 | getBooleanInput: jest.fn(), 15 | setFailed: jest.fn(), 16 | setOutput: jest.fn(), 17 | }; 18 | }); 19 | 20 | jest.mock('@actions/github', () => { 21 | const original = jest.requireActual('@actions/github'); 22 | 23 | return { 24 | getOctokit: original.getOctokit, 25 | context: { 26 | owner: 'test-owner', 27 | repo: 'test-repo', 28 | payload: { 29 | pull_request: { 30 | number: 99, 31 | }, 32 | }, 33 | }, 34 | }; 35 | }); 36 | 37 | afterEach(() => { 38 | jest.resetAllMocks(); 39 | }); 40 | 41 | describe('wait for vercel preview', () => { 42 | describe('environment setup', () => { 43 | test('exits if the token is not provided', async () => { 44 | setInputs({ 45 | token: '', 46 | }); 47 | 48 | await run(); 49 | 50 | expect(core.setFailed).toBeCalledWith( 51 | 'Required field `token` was not provided' 52 | ); 53 | }); 54 | 55 | test('exits if there is no PR number', async () => { 56 | setInputs({ 57 | token: 'a-token', 58 | }); 59 | 60 | setGithubContext({ 61 | payload: { 62 | pull_request: { 63 | number: undefined, 64 | }, 65 | }, 66 | }); 67 | 68 | await run(); 69 | 70 | expect(core.setFailed).toHaveBeenCalledWith( 71 | 'No pull request number was found' 72 | ); 73 | }); 74 | 75 | test('exits if there is no info about the PR', async () => { 76 | setInputs({ 77 | token: 'a-token', 78 | }); 79 | setGithubContext({ 80 | payload: { 81 | pull_request: { 82 | number: 99, 83 | }, 84 | }, 85 | }); 86 | ghResponse('/repos/gh-user/best-repo-ever/pulls/99', 303, {}); 87 | 88 | await run(); 89 | 90 | expect(core.setFailed).toHaveBeenCalledWith( 91 | 'Could not get information about the current pull request' 92 | ); 93 | }); 94 | 95 | test('exits if there is no Vercel deployment status found', async () => { 96 | setInputs({ 97 | token: 'a-token', 98 | max_timeout: 5, 99 | check_interval: 1, 100 | }); 101 | setGithubContext({ 102 | payload: { 103 | pull_request: { 104 | number: 99, 105 | }, 106 | }, 107 | }); 108 | ghResponse('/repos/gh-user/best-repo-ever/pulls/99', 200, { 109 | head: { 110 | sha: 'abcdef12345678', 111 | }, 112 | }); 113 | 114 | ghResponse('/repos/gh-user/best-repo-ever/deployments', 303, {}); 115 | 116 | await run(); 117 | 118 | expect(core.setFailed).toHaveBeenCalledWith( 119 | 'no vercel deployment found, exiting...' 120 | ); 121 | }); 122 | }); 123 | 124 | test('resolves the output URL from the vercel deployment', async () => { 125 | setInputs({ 126 | token: 'a-token', 127 | check_interval: 1, 128 | max_timeout: 10, 129 | }); 130 | 131 | givenValidGithubResponses(); 132 | 133 | // Simulate deployment race-condition 134 | restTimes( 135 | 'https://api.github.com/repos/gh-user/best-repo-ever/deployments', 136 | [ 137 | { 138 | status: 200, 139 | body: [ 140 | { 141 | id: 'a1a1a1', 142 | creator: { 143 | login: 'a-user', 144 | }, 145 | }, 146 | ], 147 | times: 2, 148 | }, 149 | { 150 | status: 200, 151 | body: [ 152 | { 153 | id: 'a1a1a1', 154 | creator: { 155 | login: 'a-user', 156 | }, 157 | }, 158 | { 159 | id: 'b2b2b2', 160 | creator: { 161 | login: 'vercel[bot]', 162 | }, 163 | }, 164 | ], 165 | times: 1, 166 | }, 167 | ] 168 | ); 169 | 170 | restTimes('https://my-preview.vercel.app/', [ 171 | { 172 | status: 404, 173 | body: '', 174 | times: 3, 175 | }, 176 | { 177 | status: 200, 178 | body: '', 179 | times: 1, 180 | }, 181 | ]); 182 | 183 | await run(); 184 | 185 | expect(core.setFailed).not.toBeCalled(); 186 | expect(core.setOutput).toBeCalledWith( 187 | 'url', 188 | 'https://my-preview.vercel.app/' 189 | ); 190 | }); 191 | 192 | test('can find the sha from the github context', async () => { 193 | setInputs({ 194 | token: 'a-token', 195 | check_interval: 1, 196 | max_timeout: 10, 197 | }); 198 | 199 | setGithubContext({ 200 | sha: 'abcdef12345678', 201 | }); 202 | 203 | givenValidGithubResponses(); 204 | 205 | restTimes('https://my-preview.vercel.app', [ 206 | { 207 | status: 200, 208 | body: 'ok!', 209 | times: 1, 210 | }, 211 | ]); 212 | 213 | await run(); 214 | 215 | expect(core.setFailed).not.toBeCalled(); 216 | expect(core.setOutput).toBeCalledWith( 217 | 'url', 218 | 'https://my-preview.vercel.app/' 219 | ); 220 | }); 221 | 222 | test('can wait for a specific path', async () => { 223 | setInputs({ 224 | token: 'a-token', 225 | check_interval: 1, 226 | max_timeout: 10, 227 | path: '/wp-admin.php', 228 | }); 229 | 230 | givenValidGithubResponses(); 231 | 232 | restTimes('https://my-preview.vercel.app/wp-admin.php', [ 233 | { 234 | status: 404, 235 | body: 'not found', 236 | times: 2, 237 | }, 238 | { 239 | status: 200, 240 | body: 'custom path!', 241 | times: 1, 242 | }, 243 | ]); 244 | 245 | await run(); 246 | 247 | expect(core.setFailed).not.toBeCalled(); 248 | expect(core.setOutput).toBeCalledWith( 249 | 'url', 250 | 'https://my-preview.vercel.app/' 251 | ); 252 | }); 253 | 254 | test('authenticates with the provided vercel_password', async () => { 255 | setInputs({ 256 | token: 'a-token', 257 | vercel_password: 'top-secret', 258 | check_interval: 1, 259 | }); 260 | 261 | givenValidGithubResponses(); 262 | 263 | restTimes('https://my-preview.vercel.app/', [ 264 | { 265 | status: 404, 266 | body: '', 267 | times: 2, 268 | }, 269 | { 270 | status: 200, 271 | body: '', 272 | times: 1, 273 | }, 274 | ]); 275 | 276 | server.use( 277 | rest.post('https://my-preview.vercel.app/', (req, res, ctx) => { 278 | return res( 279 | ctx.status(303), 280 | ctx.cookie('_vercel_jwt', 'a-super-secret-jwt'), 281 | ctx.body('') 282 | ); 283 | }) 284 | ); 285 | 286 | await run(); 287 | 288 | expect(core.setFailed).not.toBeCalled(); 289 | expect(core.setOutput).toHaveBeenCalledWith( 290 | 'url', 291 | 'https://my-preview.vercel.app/' 292 | ); 293 | expect(core.setOutput).toHaveBeenCalledWith( 294 | 'vercel_jwt', 295 | 'a-super-secret-jwt' 296 | ); 297 | }); 298 | 299 | test('fails if allow_inactive is set to false but the only status is inactive', async () => { 300 | setInputs({ 301 | token: 'a-token', 302 | allow_inactive: 'false', 303 | max_timeout: 5, 304 | check_interval: 1, 305 | }); 306 | 307 | setGithubContext({ 308 | payload: { 309 | pull_request: { 310 | number: 99, 311 | }, 312 | }, 313 | }); 314 | 315 | ghResponse('/repos/gh-user/best-repo-ever/pulls/99', 200, { 316 | head: { 317 | sha: 'abcdef12345678', 318 | }, 319 | }); 320 | 321 | ghResponse('/repos/gh-user/best-repo-ever/deployments', 200, [ 322 | { 323 | id: 'fake-deployment-id', 324 | creator: { 325 | login: 'vercel[bot]', 326 | }, 327 | }, 328 | ]); 329 | 330 | ghResponse( 331 | '/repos/gh-user/best-repo-ever/deployments/fake-deployment-id/statuses', 332 | 200, 333 | [ 334 | { 335 | state: 'inactive', 336 | target_url: 'https://my-preview.vercel.app/', 337 | }, 338 | ] 339 | ); 340 | 341 | await run(); 342 | 343 | expect(core.setFailed).toHaveBeenCalledWith( 344 | 'Timeout reached: Unable to wait for an deployment to be successful' 345 | ); 346 | }); 347 | 348 | test('succeeds if allow_inactive is set to true and the only status is inactive', async () => { 349 | setInputs({ 350 | token: 'a-token', 351 | allow_inactive: 'true', 352 | max_timeout: 5, 353 | check_interval: 1, 354 | }); 355 | 356 | setGithubContext({ 357 | payload: { 358 | pull_request: { 359 | number: 99, 360 | }, 361 | }, 362 | }); 363 | 364 | ghResponse('/repos/gh-user/best-repo-ever/pulls/99', 200, { 365 | head: { 366 | sha: 'abcdef12345678', 367 | }, 368 | }); 369 | 370 | ghResponse('/repos/gh-user/best-repo-ever/deployments', 200, [ 371 | { 372 | id: 'fake-deployment-id', 373 | creator: { 374 | login: 'vercel[bot]', 375 | }, 376 | }, 377 | ]); 378 | 379 | ghResponse( 380 | '/repos/gh-user/best-repo-ever/deployments/fake-deployment-id/statuses', 381 | 200, 382 | [ 383 | { 384 | state: 'inactive', 385 | target_url: 'https://my-preview.vercel.app/', 386 | }, 387 | ] 388 | ); 389 | 390 | restTimes('https://my-preview.vercel.app/', [ 391 | { 392 | status: 200, 393 | body: 'OK!', 394 | times: 1, 395 | }, 396 | ]); 397 | 398 | await run(); 399 | 400 | expect(core.setFailed).not.toHaveBeenCalled(); 401 | 402 | expect(core.setOutput).toHaveBeenCalledWith( 403 | 'url', 404 | 'https://my-preview.vercel.app/' 405 | ); 406 | }); 407 | }); 408 | 409 | /** 410 | * 411 | * @param {{ 412 | * token?: string, 413 | * vercel_password?: string; 414 | * allow_inactive?: string; 415 | * check_interval?: number; 416 | * max_timeout?: number; 417 | * path?: string; 418 | * }} inputs 419 | */ 420 | function setInputs(inputs = {}) { 421 | const spyGetInput = jest.spyOn(core, 'getInput'); 422 | const spyGetBooleanInput = jest.spyOn(core, 'getBooleanInput'); 423 | 424 | spyGetInput.mockImplementation((key) => { 425 | switch (key) { 426 | case 'token': 427 | return inputs.token || ''; 428 | case 'vercel_password': 429 | return inputs.vercel_password || ''; 430 | case 'check_interval': 431 | return `${inputs.check_interval || ''}`; 432 | case 'max_timeout': 433 | return `${inputs.max_timeout || ''}`; 434 | case 'path': 435 | return `${inputs.path || ''}`; 436 | default: 437 | return ''; 438 | } 439 | }); 440 | 441 | spyGetBooleanInput.mockImplementation((key) => { 442 | switch (key) { 443 | case 'allow_inactive': 444 | return String(inputs.allow_inactive).toLowerCase() === 'true'; 445 | default: 446 | return false; 447 | } 448 | }); 449 | } 450 | 451 | function setGithubContext(ctx) { 452 | const defaultCtx = { 453 | eventName: '', 454 | sha: '', 455 | ref: '', 456 | workflow: '', 457 | action: '', 458 | actor: '', 459 | job: '', 460 | runId: 123, 461 | runNumber: 123, 462 | apiUrl: '', 463 | serverUrl: '', 464 | graphqlUrl: '', 465 | issue: { 466 | owner: 'gh-user', 467 | repo: 'best-repo-ever', 468 | number: 345, 469 | }, 470 | repo: { 471 | owner: 'gh-user', 472 | repo: 'best-repo-ever', 473 | }, 474 | payload: { 475 | pull_request: { 476 | number: undefined, 477 | }, 478 | }, 479 | }; 480 | 481 | // ts-check complains about assigning to a read-only property 482 | // @ts-ignore 483 | github.context = deepmerge(defaultCtx, ctx); 484 | } 485 | 486 | function ghResponse(uri, status, data) { 487 | server.use( 488 | rest.get(`https://api.github.com${uri}`, (req, res, ctx) => { 489 | return res(ctx.status(status), ctx.json(data)); 490 | }) 491 | ); 492 | } 493 | 494 | function ghRespondOnce(uri, status, data) { 495 | return restOnce(`https://api.github.com${uri}`, status, data); 496 | } 497 | 498 | function restOnce(uri, status, data) { 499 | server.use( 500 | rest.get(uri, (req, res, ctx) => { 501 | return res.once(ctx.status(status), ctx.json(data)); 502 | }) 503 | ); 504 | } 505 | 506 | function restTimes(uri, payloads) { 507 | let count = 0; 508 | let cursor = 0; 509 | 510 | server.use( 511 | rest.get(uri, (req, res, ctx) => { 512 | let payload = payloads[cursor]; 513 | 514 | if (count < payload.times) { 515 | count = count + 1; 516 | 517 | if (typeof payload.body === 'string') { 518 | return res(ctx.status(payload.status), ctx.body(payload.body)); 519 | } 520 | 521 | return res(ctx.status(payload.status), ctx.json(payload.body)); 522 | } 523 | 524 | cursor = cursor + 1; 525 | count = 1; 526 | payload = payloads[cursor]; 527 | 528 | if (typeof payload.body === 'string') { 529 | return res(ctx.status(payload.status), ctx.body(payload.body)); 530 | } 531 | 532 | return res(ctx.status(payload.status), ctx.json(payload.body)); 533 | }) 534 | ); 535 | } 536 | 537 | function givenValidGithubResponses() { 538 | setGithubContext({ 539 | payload: { 540 | pull_request: { 541 | number: 99, 542 | }, 543 | }, 544 | }); 545 | 546 | ghResponse('/repos/gh-user/best-repo-ever/pulls/99', 200, { 547 | head: { 548 | sha: 'abcdef12345678', 549 | }, 550 | }); 551 | 552 | ghResponse('/repos/gh-user/best-repo-ever/deployments', 200, [ 553 | { 554 | id: 'a1a1a1', 555 | creator: { 556 | login: 'a-user', 557 | }, 558 | }, 559 | { 560 | id: 'b2b2b2', 561 | creator: { 562 | login: 'vercel[bot]', 563 | }, 564 | }, 565 | ]); 566 | 567 | const statusEndpoint = 568 | '/repos/gh-user/best-repo-ever/deployments/b2b2b2/statuses'; 569 | 570 | ghRespondOnce(statusEndpoint, 200, [ 571 | { 572 | state: 'in-progress', 573 | }, 574 | ]); 575 | 576 | ghRespondOnce(statusEndpoint, 200, [ 577 | { 578 | state: 'success', 579 | target_url: 'https://my-preview.vercel.app/', 580 | }, 581 | ]); 582 | } 583 | --------------------------------------------------------------------------------