From 24b5a87701da842d652236f7f6c9253543690f2a Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Thu, 12 Sep 2024 15:54:39 +0200 Subject: [PATCH 1/8] add basic auth options --- action.js | 9 +++++++++ action.yml | 3 +++ 2 files changed, 12 insertions(+) diff --git a/action.js b/action.js index 15a1e676..ed26b70c 100644 --- a/action.js +++ b/action.js @@ -15,6 +15,7 @@ const waitForUrl = async ({ maxTimeout, checkIntervalInMilliseconds, vercelPassword, + basicAuthCredentials, protectionBypassHeader, path, }) => { @@ -46,6 +47,12 @@ const waitForUrl = async ({ }; } + if (basicAuthCredentials) { + headers = { + 'Authorization': `Basic ${basicAuthCredentials}` + }; + } + let checkUri = new URL(path, url); await axios.get(checkUri.toString(), { @@ -289,6 +296,7 @@ const run = async () => { const VERCEL_PASSWORD = core.getInput('vercel_password'); const VERCEL_PROTECTION_BYPASS_HEADER = core.getInput('vercel_protection_bypass_header'); const ENVIRONMENT = core.getInput('environment'); + const BASIC_AUTH_CREDENTIALS_BASE_64 = core.getInput('basic_auth_credentials_base64'); const MAX_TIMEOUT = Number(core.getInput('max_timeout')) || 60; const ALLOW_INACTIVE = Boolean(core.getInput('allow_inactive')) || false; const PATH = core.getInput('path') || '/'; @@ -376,6 +384,7 @@ const run = async () => { checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, vercelPassword: VERCEL_PASSWORD, protectionBypassHeader: VERCEL_PROTECTION_BYPASS_HEADER, + basicAuthCredentials: BASIC_AUTH_CREDENTIALS_BASE_64, path: PATH, }); } catch (error) { diff --git a/action.yml b/action.yml index 290c9cca..7ca5fb38 100644 --- a/action.yml +++ b/action.yml @@ -26,6 +26,9 @@ inputs: vercel_protection_bypass_header: description: 'Vercel protection bypass for automation' required: false + basic_auth_credentials_base64: + description: 'Basic Auth base64 encoded credentials' + required: false path: description: 'The path to check. Defaults to the index of the domain' default: '/' From dac6b9db60a21bfc20934664157f1d8e9059229c Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Thu, 12 Sep 2024 16:06:05 +0200 Subject: [PATCH 2/8] change action name --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 7ca5fb38..967a77d6 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: 'Wait for Vercel Preview' +name: 'Wait for Vercel Preview with Basic Auth' description: 'Wait for Vercel Deploy Preview to complete. Requires to be run on pull_request or push.' branding: icon: 'clock' From feab4a2e55e0ff94faffc2334bc7f79e92bda15f Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Thu, 12 Sep 2024 16:30:25 +0200 Subject: [PATCH 3/8] add option to skip health check --- action.js | 6 ++++++ action.yml | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/action.js b/action.js index ed26b70c..e8b3d5e0 100644 --- a/action.js +++ b/action.js @@ -297,6 +297,7 @@ const run = async () => { const VERCEL_PROTECTION_BYPASS_HEADER = core.getInput('vercel_protection_bypass_header'); const ENVIRONMENT = core.getInput('environment'); const BASIC_AUTH_CREDENTIALS_BASE_64 = core.getInput('basic_auth_credentials_base64'); + const SKIP_HEALTH_CHECK = core.getInput('skip_health_check') === 'true'; const MAX_TIMEOUT = Number(core.getInput('max_timeout')) || 60; const ALLOW_INACTIVE = Boolean(core.getInput('allow_inactive')) || false; const PATH = core.getInput('path') || '/'; @@ -378,6 +379,11 @@ const run = async () => { // Wait for url to respond with a success console.log(`Waiting for a status code 200 from: ${targetUrl}`); + if (SKIP_HEALTH_CHECK) { + console.log('Skipping health check'); + return; + } + await waitForUrl({ url: targetUrl, maxTimeout: MAX_TIMEOUT, diff --git a/action.yml b/action.yml index 967a77d6..1ab9bf79 100644 --- a/action.yml +++ b/action.yml @@ -29,6 +29,10 @@ inputs: basic_auth_credentials_base64: description: 'Basic Auth base64 encoded credentials' required: false + skip_health_check: + default: 'false' + description: 'Basic Auth base64 encoded credentials' + required: false path: description: 'The path to check. Defaults to the index of the domain' default: '/' From 1a35fb989bcaf843ff1b737cc87992644bdb554a Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Thu, 12 Sep 2024 16:36:55 +0200 Subject: [PATCH 4/8] run build --- dist/index.js | 247 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 164 insertions(+), 83 deletions(-) diff --git a/dist/index.js b/dist/index.js index 30fde42a..de720a5e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -21,6 +21,8 @@ const waitForUrl = async ({ maxTimeout, checkIntervalInMilliseconds, vercelPassword, + basicAuthCredentials, + protectionBypassHeader, path, }) => { const iterations = calculateIterations( @@ -45,6 +47,18 @@ const waitForUrl = async ({ core.setOutput('vercel_jwt', jwt); } + if (protectionBypassHeader) { + headers = { + 'x-vercel-protection-bypass': protectionBypassHeader + }; + } + + if (basicAuthCredentials) { + headers = { + 'Authorization': `Basic ${basicAuthCredentials}` + }; + } + let checkUri = new URL(path, url); await axios.get(checkUri.toString(), { @@ -234,15 +248,22 @@ const waitForDeploymentToStart = async ({ return deployment; } - throw new Error(`no ${actorName} deployment found`); - } catch (e) { console.log( `Could not find any deployments for actor ${actorName}, retrying (attempt ${ i + 1 } / ${iterations})` ); - await wait(checkIntervalInMilliseconds); + } catch(e) { + console.log( + `Error while fetching deployments, retrying (attempt ${ + i + 1 + } / ${iterations})` + ); + + console.error(e) } + + await wait(checkIntervalInMilliseconds); } return null; @@ -279,7 +300,10 @@ const run = async () => { // Inputs const GITHUB_TOKEN = core.getInput('token', { required: true }); const VERCEL_PASSWORD = core.getInput('vercel_password'); + const VERCEL_PROTECTION_BYPASS_HEADER = core.getInput('vercel_protection_bypass_header'); const ENVIRONMENT = core.getInput('environment'); + const BASIC_AUTH_CREDENTIALS_BASE_64 = core.getInput('basic_auth_credentials_base64'); + const SKIP_HEALTH_CHECK = core.getInput('skip_health_check') === 'true'; const MAX_TIMEOUT = Number(core.getInput('max_timeout')) || 60; const ALLOW_INACTIVE = Boolean(core.getInput('allow_inactive')) || false; const PATH = core.getInput('path') || '/'; @@ -326,7 +350,7 @@ const run = async () => { sha: sha, environment: ENVIRONMENT, actorName: 'vercel[bot]', - maxTimeout: MAX_TIMEOUT / 2, + maxTimeout: MAX_TIMEOUT, checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, }); @@ -361,11 +385,18 @@ const run = async () => { // Wait for url to respond with a success console.log(`Waiting for a status code 200 from: ${targetUrl}`); + if (SKIP_HEALTH_CHECK) { + console.log('Skipping health check'); + return; + } + await waitForUrl({ url: targetUrl, maxTimeout: MAX_TIMEOUT, checkIntervalInMilliseconds: CHECK_INTERVAL_IN_MS, vercelPassword: VERCEL_PASSWORD, + protectionBypassHeader: VERCEL_PROTECTION_BYPASS_HEADER, + basicAuthCredentials: BASIC_AUTH_CREDENTIALS_BASE_64, path: PATH, }); } catch (error) { @@ -6577,6 +6608,29 @@ var Writable = (__nccwpck_require__(2781).Writable); var assert = __nccwpck_require__(9491); var debug = __nccwpck_require__(1133); +// Whether to use the native URL object or the legacy url module +var useNativeURL = false; +try { + assert(new URL()); +} +catch (error) { + useNativeURL = error.code === "ERR_INVALID_URL"; +} + +// URL fields to preserve in copy operations +var preservedUrlFields = [ + "auth", + "host", + "hostname", + "href", + "path", + "pathname", + "port", + "protocol", + "query", + "search", +]; + // Create handlers that pass events from native requests var events = ["abort", "aborted", "connect", "error", "socket", "timeout"]; var eventHandlers = Object.create(null); @@ -6586,19 +6640,20 @@ events.forEach(function (event) { }; }); +// Error types with codes var InvalidUrlError = createErrorType( "ERR_INVALID_URL", "Invalid URL", TypeError ); -// Error types with codes var RedirectionError = createErrorType( "ERR_FR_REDIRECTION_FAILURE", "Redirected request failed" ); var TooManyRedirectsError = createErrorType( "ERR_FR_TOO_MANY_REDIRECTS", - "Maximum number of redirects exceeded" + "Maximum number of redirects exceeded", + RedirectionError ); var MaxBodyLengthExceededError = createErrorType( "ERR_FR_MAX_BODY_LENGTH_EXCEEDED", @@ -6609,6 +6664,9 @@ var WriteAfterEndError = createErrorType( "write after end" ); +// istanbul ignore next +var destroy = Writable.prototype.destroy || noop; + // An HTTP(S) request that can be redirected function RedirectableRequest(options, responseCallback) { // Initialize the request @@ -6630,7 +6688,13 @@ function RedirectableRequest(options, responseCallback) { // React to responses of native requests var self = this; this._onNativeResponse = function (response) { - self._processResponse(response); + try { + self._processResponse(response); + } + catch (cause) { + self.emit("error", cause instanceof RedirectionError ? + cause : new RedirectionError({ cause: cause })); + } }; // Perform the first request @@ -6639,10 +6703,17 @@ function RedirectableRequest(options, responseCallback) { RedirectableRequest.prototype = Object.create(Writable.prototype); RedirectableRequest.prototype.abort = function () { - abortRequest(this._currentRequest); + destroyRequest(this._currentRequest); + this._currentRequest.abort(); this.emit("abort"); }; +RedirectableRequest.prototype.destroy = function (error) { + destroyRequest(this._currentRequest, error); + destroy.call(this, error); + return this; +}; + // Writes buffered data to the current native request RedirectableRequest.prototype.write = function (data, encoding, callback) { // Writing is not allowed if end has been called @@ -6755,6 +6826,7 @@ RedirectableRequest.prototype.setTimeout = function (msecs, callback) { self.removeListener("abort", clearTimer); self.removeListener("error", clearTimer); self.removeListener("response", clearTimer); + self.removeListener("close", clearTimer); if (callback) { self.removeListener("timeout", callback); } @@ -6781,6 +6853,7 @@ RedirectableRequest.prototype.setTimeout = function (msecs, callback) { this.on("abort", clearTimer); this.on("error", clearTimer); this.on("response", clearTimer); + this.on("close", clearTimer); return this; }; @@ -6839,8 +6912,7 @@ RedirectableRequest.prototype._performRequest = function () { var protocol = this._options.protocol; var nativeProtocol = this._options.nativeProtocols[protocol]; if (!nativeProtocol) { - this.emit("error", new TypeError("Unsupported protocol " + protocol)); - return; + throw new TypeError("Unsupported protocol " + protocol); } // If specified, use the agent corresponding to the protocol @@ -6932,15 +7004,14 @@ RedirectableRequest.prototype._processResponse = function (response) { } // The response is a redirect, so abort the current request - abortRequest(this._currentRequest); + destroyRequest(this._currentRequest); // Discard the remainder of the response to avoid waiting for data response.destroy(); // RFC7231ยง6.4: A client SHOULD detect and intervene // in cyclical redirections (i.e., "infinite" redirection loops). if (++this._redirectCount > this._options.maxRedirects) { - this.emit("error", new TooManyRedirectsError()); - return; + throw new TooManyRedirectsError(); } // Store the request headers if applicable @@ -6974,33 +7045,23 @@ RedirectableRequest.prototype._processResponse = function (response) { var currentHostHeader = removeMatchingHeaders(/^host$/i, this._options.headers); // If the redirect is relative, carry over the host of the last request - var currentUrlParts = url.parse(this._currentUrl); + var currentUrlParts = parseUrl(this._currentUrl); var currentHost = currentHostHeader || currentUrlParts.host; var currentUrl = /^\w+:/.test(location) ? this._currentUrl : url.format(Object.assign(currentUrlParts, { host: currentHost })); - // Determine the URL of the redirection - var redirectUrl; - try { - redirectUrl = url.resolve(currentUrl, location); - } - catch (cause) { - this.emit("error", new RedirectionError({ cause: cause })); - return; - } - // Create the redirected request - debug("redirecting to", redirectUrl); + var redirectUrl = resolveUrl(location, currentUrl); + debug("redirecting to", redirectUrl.href); this._isRedirect = true; - var redirectUrlParts = url.parse(redirectUrl); - Object.assign(this._options, redirectUrlParts); + spreadUrlObject(redirectUrl, this._options); // Drop confidential headers when redirecting to a less secure protocol // or to a different domain that is not a superdomain - if (redirectUrlParts.protocol !== currentUrlParts.protocol && - redirectUrlParts.protocol !== "https:" || - redirectUrlParts.host !== currentHost && - !isSubdomain(redirectUrlParts.host, currentHost)) { + if (redirectUrl.protocol !== currentUrlParts.protocol && + redirectUrl.protocol !== "https:" || + redirectUrl.host !== currentHost && + !isSubdomain(redirectUrl.host, currentHost)) { removeMatchingHeaders(/^(?:authorization|cookie)$/i, this._options.headers); } @@ -7015,23 +7076,12 @@ RedirectableRequest.prototype._processResponse = function (response) { method: method, headers: requestHeaders, }; - try { - beforeRedirect(this._options, responseDetails, requestDetails); - } - catch (err) { - this.emit("error", err); - return; - } + beforeRedirect(this._options, responseDetails, requestDetails); this._sanitizeOptions(this._options); } // Perform the redirected request - try { - this._performRequest(); - } - catch (cause) { - this.emit("error", new RedirectionError({ cause: cause })); - } + this._performRequest(); }; // Wraps the key/value object of protocols with redirect functionality @@ -7051,27 +7101,16 @@ function wrap(protocols) { // Executes a request, following redirects function request(input, options, callback) { - // Parse parameters - if (isString(input)) { - var parsed; - try { - parsed = urlToOptions(new URL(input)); - } - catch (err) { - /* istanbul ignore next */ - parsed = url.parse(input); - } - if (!isString(parsed.protocol)) { - throw new InvalidUrlError({ input }); - } - input = parsed; + // Parse parameters, ensuring that input is an object + if (isURL(input)) { + input = spreadUrlObject(input); } - else if (URL && (input instanceof URL)) { - input = urlToOptions(input); + else if (isString(input)) { + input = spreadUrlObject(parseUrl(input)); } else { callback = options; - options = input; + options = validateUrl(input); input = { protocol: protocol }; } if (isFunction(options)) { @@ -7110,27 +7149,57 @@ function wrap(protocols) { return exports; } -/* istanbul ignore next */ function noop() { /* empty */ } -// from https://github.com/nodejs/node/blob/master/lib/internal/url.js -function urlToOptions(urlObject) { - var options = { - protocol: urlObject.protocol, - hostname: urlObject.hostname.startsWith("[") ? - /* istanbul ignore next */ - urlObject.hostname.slice(1, -1) : - urlObject.hostname, - hash: urlObject.hash, - search: urlObject.search, - pathname: urlObject.pathname, - path: urlObject.pathname + urlObject.search, - href: urlObject.href, - }; - if (urlObject.port !== "") { - options.port = Number(urlObject.port); +function parseUrl(input) { + var parsed; + /* istanbul ignore else */ + if (useNativeURL) { + parsed = new URL(input); + } + else { + // Ensure the URL is valid and absolute + parsed = validateUrl(url.parse(input)); + if (!isString(parsed.protocol)) { + throw new InvalidUrlError({ input }); + } + } + return parsed; +} + +function resolveUrl(relative, base) { + /* istanbul ignore next */ + return useNativeURL ? new URL(relative, base) : parseUrl(url.resolve(base, relative)); +} + +function validateUrl(input) { + if (/^\[/.test(input.hostname) && !/^\[[:0-9a-f]+\]$/i.test(input.hostname)) { + throw new InvalidUrlError({ input: input.href || input }); + } + if (/^\[/.test(input.host) && !/^\[[:0-9a-f]+\](:\d+)?$/i.test(input.host)) { + throw new InvalidUrlError({ input: input.href || input }); } - return options; + return input; +} + +function spreadUrlObject(urlObject, target) { + var spread = target || {}; + for (var key of preservedUrlFields) { + spread[key] = urlObject[key]; + } + + // Fix IPv6 hostname + if (spread.hostname.startsWith("[")) { + spread.hostname = spread.hostname.slice(1, -1); + } + // Ensure port is a number + if (spread.port !== "") { + spread.port = Number(spread.port); + } + // Concatenate path + spread.path = spread.search ? spread.pathname + spread.search : spread.pathname; + + return spread; } function removeMatchingHeaders(regex, headers) { @@ -7156,17 +7225,25 @@ function createErrorType(code, message, baseClass) { // Attach constructor and set default properties CustomError.prototype = new (baseClass || Error)(); - CustomError.prototype.constructor = CustomError; - CustomError.prototype.name = "Error [" + code + "]"; + Object.defineProperties(CustomError.prototype, { + constructor: { + value: CustomError, + enumerable: false, + }, + name: { + value: "Error [" + code + "]", + enumerable: false, + }, + }); return CustomError; } -function abortRequest(request) { +function destroyRequest(request, error) { for (var event of events) { request.removeListener(event, eventHandlers[event]); } request.on("error", noop); - request.abort(); + request.destroy(error); } function isSubdomain(subdomain, domain) { @@ -7187,6 +7264,10 @@ function isBuffer(value) { return typeof value === "object" && ("length" in value); } +function isURL(value) { + return URL && value instanceof URL; +} + // Exports module.exports = wrap({ http: http, https: https }); module.exports.wrap = wrap; From a2a61d4402db09918451cccbebc00d5a787235ed Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Fri, 13 Sep 2024 09:17:17 +0200 Subject: [PATCH 5/8] add documentation --- README.md | 7 +++++++ action.yml | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ee94438..c818a523 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ Optional - The [password](https://vercel.com/docs/concepts/projects/overview#pas Optional - The [header](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) to bypass protection for automation +### `basic_auth_credentials_base64` + +Optional - Use if your app is protected with basic auth. provide your base64 encoded credentials in form `username:password`, see [basic auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization#basic_authentication) + +### skip_health_check +Optional - Skip the health check for status code 200 and return the URL immediately after successful deployment. Defaults to `false`. + ### `path` Optional - The URL that tests should run against (eg. `path: "https://vercel.com"`). diff --git a/action.yml b/action.yml index 1ab9bf79..873caee0 100644 --- a/action.yml +++ b/action.yml @@ -27,11 +27,11 @@ inputs: description: 'Vercel protection bypass for automation' required: false basic_auth_credentials_base64: - description: 'Basic Auth base64 encoded credentials' + description: 'In case the app is protected with Basic Auth, provide base64 encoded credentials in form `username:password`' required: false skip_health_check: default: 'false' - description: 'Basic Auth base64 encoded credentials' + description: 'Skip health check for status code 200 after deployment' required: false path: description: 'The path to check. Defaults to the index of the domain' From 260f6cfc0bcfdea9896a615d518c58295b9e0d07 Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Fri, 13 Sep 2024 09:19:58 +0200 Subject: [PATCH 6/8] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c818a523..6271c376 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Optional - The [header](https://vercel.com/docs/security/deployment-protection/m Optional - Use if your app is protected with basic auth. provide your base64 encoded credentials in form `username:password`, see [basic auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization#basic_authentication) -### skip_health_check +### `skip_health_check`` Optional - Skip the health check for status code 200 and return the URL immediately after successful deployment. Defaults to `false`. ### `path` From 3bdf2aa300d9004381399ae7446ac016d3abac1b Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Fri, 13 Sep 2024 09:26:29 +0200 Subject: [PATCH 7/8] revert action name change --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 873caee0..9df233f1 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: 'Wait for Vercel Preview with Basic Auth' +name: 'Wait for Vercel Preview' description: 'Wait for Vercel Deploy Preview to complete. Requires to be run on pull_request or push.' branding: icon: 'clock' From 9c0e5da3247d823362d83313ab1d7a24aa0b7480 Mon Sep 17 00:00:00 2001 From: Oliver Franke Date: Fri, 13 Sep 2024 10:17:02 +0200 Subject: [PATCH 8/8] fix skip health check log message to occur before the health check message --- action.js | 6 +++--- dist/index.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/action.js b/action.js index e8b3d5e0..81b977f7 100644 --- a/action.js +++ b/action.js @@ -376,14 +376,14 @@ const run = async () => { // Set output core.setOutput('url', targetUrl); - // Wait for url to respond with a success - console.log(`Waiting for a status code 200 from: ${targetUrl}`); - if (SKIP_HEALTH_CHECK) { console.log('Skipping health check'); return; } + // Wait for url to respond with a success + console.log(`Waiting for a status code 200 from: ${targetUrl}`); + await waitForUrl({ url: targetUrl, maxTimeout: MAX_TIMEOUT, diff --git a/dist/index.js b/dist/index.js index de720a5e..dd5777be 100644 --- a/dist/index.js +++ b/dist/index.js @@ -382,14 +382,14 @@ const run = async () => { // Set output core.setOutput('url', targetUrl); - // Wait for url to respond with a success - console.log(`Waiting for a status code 200 from: ${targetUrl}`); - if (SKIP_HEALTH_CHECK) { console.log('Skipping health check'); return; } + // Wait for url to respond with a success + console.log(`Waiting for a status code 200 from: ${targetUrl}`); + await waitForUrl({ url: targetUrl, maxTimeout: MAX_TIMEOUT,