diff --git a/lib/get-git-auth-url.js b/lib/get-git-auth-url.js index c6c3e8b8e9..0c35cd299d 100644 --- a/lib/get-git-auth-url.js +++ b/lib/get-git-auth-url.js @@ -4,6 +4,48 @@ const hostedGitInfo = require('hosted-git-info'); const {verifyAuth} = require('./git'); const debug = require('debug')('semantic-release:get-git-auth-url'); +/** + * Machinery to format a repository URL with the given credentials + * + * @param {String} protocol URL protocol (which should not be present in repositoryUrl) + * @param {String} repositoryUrl User-given repository URL + * @param {String} gitCredentials The basic auth part of the URL + * + * @return {String} The formatted Git repository URL. + */ +function formatAuthUrl(protocol, repositoryUrl, gitCredentials) { + const [match, auth, host, basePort, path] = + /^(?!.+:\/\/)(?:(?.*)@)?(?.*?):(?\d+)?:?\/?(?.*)$/.exec(repositoryUrl) || []; + const {port, hostname, ...parsed} = parse( + match ? `ssh://${auth ? `${auth}@` : ''}${host}${basePort ? `:${basePort}` : ''}/${path}` : repositoryUrl + ); + + return format({ + ...parsed, + auth: gitCredentials, + host: `${hostname}${protocol === 'ssh:' ? '' : port ? `:${port}` : ''}`, + protocol: protocol && /http[^s]/.test(protocol) ? 'http' : 'https', + }); +} + +/** + * Verify authUrl by calling git.verifyAuth, but don't throw on failure + * + * @param {Object} context semantic-release context. + * @param {String} authUrl Repository URL to verify + * + * @return {String} The authUrl as is if the connection was successfull, null otherwise + */ +async function ensureValidAuthUrl({cwd, env, branch}, authUrl) { + try { + await verifyAuth(authUrl, branch.name, {cwd, env}); + return authUrl; + } catch (error) { + debug(error); + return null; + } +} + /** * Determine the the git repository URL to use to push, either: * - The `repositoryUrl` as is if allowed to push @@ -15,7 +57,8 @@ const debug = require('debug')('semantic-release:get-git-auth-url'); * * @return {String} The formatted Git repository URL. */ -module.exports = async ({cwd, env, branch, options: {repositoryUrl}}) => { +module.exports = async (context) => { + const {cwd, env, branch} = context; const GIT_TOKENS = { GIT_CREDENTIALS: undefined, GH_TOKEN: undefined, @@ -30,6 +73,7 @@ module.exports = async ({cwd, env, branch, options: {repositoryUrl}}) => { BITBUCKET_TOKEN_BASIC_AUTH: '', }; + let {repositoryUrl} = context.options; const info = hostedGitInfo.fromUrl(repositoryUrl, {noGitPlus: true}); const {protocol, ...parsed} = parse(repositoryUrl); @@ -47,24 +91,30 @@ module.exports = async ({cwd, env, branch, options: {repositoryUrl}}) => { await verifyAuth(repositoryUrl, branch.name, {cwd, env}); } catch (_) { debug('SSH key auth failed, falling back to https.'); + const envVars = Object.keys(GIT_TOKENS).filter((envVar) => !isNil(env[envVar])); + + // Skip verification if there is no ambiguity on which env var to use for authentication + if (envVars.length === 1) { + const gitCredentials = `${GIT_TOKENS[envVars[0]] || ''}${env[envVars[0]]}`; + return formatAuthUrl(protocol, repositoryUrl, gitCredentials); + } - const envVar = Object.keys(GIT_TOKENS).find((envVar) => !isNil(env[envVar])); - const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`; + if (envVars.length > 1) { + debug(`Found ${envVars.length} credentials in environment, trying all of them`); - if (gitCredentials) { - // If credentials are set via environment variables, convert the URL to http/https and add basic auth, otherwise return `repositoryUrl` as is - const [match, auth, host, basePort, path] = - /^(?!.+:\/\/)(?:(?.*)@)?(?.*?):(?\d+)?:?\/?(?.*)$/.exec(repositoryUrl) || []; - const {port, hostname, ...parsed} = parse( - match ? `ssh://${auth ? `${auth}@` : ''}${host}${basePort ? `:${basePort}` : ''}/${path}` : repositoryUrl - ); + const candidateRepositoryUrls = []; + for (const envVar of envVars) { + const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar]}`; + const authUrl = formatAuthUrl(protocol, repositoryUrl, gitCredentials); + candidateRepositoryUrls.push(ensureValidAuthUrl(context, authUrl)); + } - return format({ - ...parsed, - auth: gitCredentials, - host: `${hostname}${protocol === 'ssh:' ? '' : port ? `:${port}` : ''}`, - protocol: protocol && /http[^s]/.test(protocol) ? 'http' : 'https', - }); + const validRepositoryUrls = await Promise.all(candidateRepositoryUrls); + const chosenAuthUrlIndex = validRepositoryUrls.findIndex((url) => url !== null); + if (chosenAuthUrlIndex > -1) { + debug(`Using "${envVars[chosenAuthUrlIndex]}" to authenticate`); + return validRepositoryUrls[chosenAuthUrlIndex]; + } } } diff --git a/test/integration.test.js b/test/integration.test.js index cf3323f251..34749b272c 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -6,6 +6,7 @@ const {writeJson, readJson} = require('fs-extra'); const execa = require('execa'); const {WritableStreamBuffer} = require('stream-buffers'); const delay = require('delay'); +const getAuthUrl = require('../lib/get-git-auth-url'); const {SECRET_REPLACEMENT} = require('../lib/definitions/constants'); const { gitHead, @@ -656,3 +657,43 @@ test('Hide sensitive environment variable values from the logs', async (t) => { t.regex(stderr, new RegExp(`Error: Console token ${escapeRegExp(SECRET_REPLACEMENT)}`)); t.regex(stderr, new RegExp(`Throw error: Exposing ${escapeRegExp(SECRET_REPLACEMENT)}`)); }); + +test('Use the valid git credentials when multiple are provided', async (t) => { + const {cwd, authUrl} = await gitbox.createRepo('test-auth'); + + t.is( + await getAuthUrl({ + cwd, + env: { + GITHUB_TOKEN: 'dummy', + GITLAB_TOKEN: 'trash', + BB_TOKEN_BASIC_AUTH: gitbox.gitCredential, + GIT_ASKPASS: 'echo', + GIT_TERMINAL_PROMPT: 0, + }, + branch: {name: 'master'}, + options: {repositoryUrl: 'http://toto@localhost:2080/git/test-auth.git'}, + }), + authUrl + ); +}); + +test('Use the repository URL as is if none of the given git credentials are valid', async (t) => { + const {cwd} = await gitbox.createRepo('test-invalid-auth'); + const dummyUrl = 'http://toto@localhost:2080/git/test-auth.git'; + + t.is( + await getAuthUrl({ + cwd, + env: { + GITHUB_TOKEN: 'dummy', + GITLAB_TOKEN: 'trash', + GIT_ASKPASS: 'echo', + GIT_TERMINAL_PROMPT: 0, + }, + branch: {name: 'master'}, + options: {repositoryUrl: dummyUrl}, + }), + dummyUrl + ); +});