diff --git a/README.md b/README.md index 52c34296..b30844cb 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,19 @@ With this example [GitLab releases](https://docs.gitlab.com/ee/user/project/rele The GitLab authentication configuration is **required** and can be set via [environment variables](#environment-variables). +There are two types of tokens supported by GitLab: + +#### Access Token + Create a [project access token](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html), [group access token](https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html), or [personal access token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html) with role _Developer_ (or higher) and the `api` scope and make it available in your CI environment via the `GL_TOKEN` environment variable. If you are using `GL_TOKEN` as the [remote Git repository authentication](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/ci-configuration.md#authentication) it must also have the `write_repository` scope. **Note**: When running with [`dryRun`](https://semantic-release.gitbook.io/semantic-release/usage/configuration#dryrun) only `read_repository` scope is required. +#### Job Token +Ensure your project is configured to [allow git push requests for job tokens](https://docs.gitlab.com/ci/jobs/ci_job_token/#allow-git-push-requests-to-your-project-repository), and assign the value of `CI_JOB_TOKEN` to `GL_TOKEN`. + +**Note**: Due to limitations on [job token](https://docs.gitlab.com/ci/jobs/ci_job_token/) access, comments on merge requests and issues must be explicitly disabled. See: [successCommentCondition](#successcommentcondition) and [failCommentCondition](#failcommentcondition). + ### Environment variables | Variable | Description | diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 51508538..65401e48 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -49,13 +49,13 @@ By default the \`repositoryUrl\` option is retrieved from the \`repository\` pro message: 'Invalid GitLab token.', details: `The [GitLab token](${linkify( 'README.md#gitlab-authentication' - )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must be a valid [personal access token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html) allowing to push to the repository ${projectPath}. + )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must be a valid [ci job token](https://docs.gitlab.com/ci/jobs/ci_job_token/), [group access token](https://docs.gitlab.com/user/group/settings/group_access_tokens/), [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/), or [personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/) with access to the repository ${projectPath}. -Please make sure to set the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable in your CI with the exact value of the GitLab personal token.`, +Please make sure to set the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable in your CI with the exact value of the GitLab token.`, }), EMISSINGREPO: ({projectPath}) => ({ message: `The repository ${projectPath} doesn't exist.`, - details: `The **semantic-release** \`repositoryUrl\` option must refer to your GitLab repository. The repository must be accessible with the [GitLab API](https://docs.gitlab.com/ce/api/README.html). + details: `The **semantic-release** \`repositoryUrl\` option must refer to your GitLab repository. The repository must be accessible with the [GitLab API](https://docs.gitlab.com/api/rest/). By default the \`repositoryUrl\` option is retrieved from the \`repository\` property of your \`package.json\` or the [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) of the repository cloned by your CI environment. @@ -65,15 +65,15 @@ If you are using [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee) }), EGLNOPUSHPERMISSION: ({projectPath}) => ({ message: `The GitLab token doesn't allow to push on the repository ${projectPath}.`, - details: `The user associated with the [GitLab token](${linkify( + details: `The access associated with the [GitLab token](${linkify( 'README.md#gitlab-authentication' - )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allows to push to the repository ${projectPath}. + )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allow push to the repository ${projectPath}. Please make sure the GitLab user associated with the token has the [permission to push](https://docs.gitlab.com/ee/user/permissions.html#project-members-permissions) to the repository ${projectPath}.`, }), EGLNOPULLPERMISSION: ({projectPath}) => ({ message: `The GitLab token doesn't allow to pull from the repository ${projectPath}.`, - details: `The user associated with the [GitLab token](${linkify( + details: `The access associated with the [GitLab token](${linkify( 'README.md#gitlab-authentication' )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allow pull from the repository ${projectPath}. @@ -85,6 +85,13 @@ Please make sure the GitLab user associated with the token has the [permission t 'README.md#gitlab-authentication' )}) must be created and set in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable on your CI environment. -Please make sure to create a [GitLab personal access token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html) and to set it in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable on your CI environment. The token must allow to push to the repository ${repositoryUrl}.`, +Please make sure to create a [group access token](https://docs.gitlab.com/user/group/settings/group_access_tokens/), [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/), [personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/), or utilize the [ci job token](https://docs.gitlab.com/ci/jobs/ci_job_token/) and to set it in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable on your CI environment. The token must allow access to the repository ${repositoryUrl}.`, + }), + + EJOBTOKENCOMMENTCONDITION: ({projectPath}) => ({ + message: 'Invalid comment conditions using job token.', + details: `When using a [job token](https://docs.gitlab.com/ci/jobs/ci_job_token/), [successCommentCondition](${linkify('README.md#successCommentCondition')}) and [failCommentCondition](${linkify('README.md#failCommentCondition')}) must be explicitly set to \`false\`, as job tokens do not have permissions to comment on issues and merge requests. + +Please explicitly disable this function, or use a [group access token](https://docs.gitlab.com/user/group/settings/group_access_tokens/), [project access token](https://docs.gitlab.com/user/project/settings/project_access_tokens/), or [personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/) with access to the repository ${projectPath}`, }), }; diff --git a/lib/fail.js b/lib/fail.js index 5f878c4d..ce7221b6 100644 --- a/lib/fail.js +++ b/lib/fail.js @@ -16,6 +16,7 @@ export default async (pluginConfig, context) => { } = context; const { gitlabToken, + tokenHeader, gitlabUrl, gitlabApiUrl, failComment, @@ -29,7 +30,7 @@ export default async (pluginConfig, context) => { const { encodedProjectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); const apiOptions = { - headers: { "PRIVATE-TOKEN": gitlabToken }, + headers: { [tokenHeader]: gitlabToken }, retry: { limit: retryLimit, statusCodes: retryStatusCodes, diff --git a/lib/publish.js b/lib/publish.js index 4a8f9511..914a7a2e 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -22,7 +22,7 @@ export default async (pluginConfig, context) => { nextRelease: { gitTag, gitHead, notes, version }, logger, } = context; - const { gitlabToken, gitlabUrl, gitlabApiUrl, assets, milestones, proxy, retryLimit, retryStatusCodes } = + const { gitlabToken, tokenHeader, gitlabUrl, gitlabApiUrl, assets, milestones, proxy, retryLimit, retryStatusCodes } = resolveConfig(pluginConfig, context); const assetsList = []; const { projectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); @@ -31,7 +31,7 @@ export default async (pluginConfig, context) => { const encodedVersion = encodeURIComponent(version); const apiOptions = { headers: { - "PRIVATE-TOKEN": gitlabToken, + [tokenHeader]: gitlabToken, }, hooks: { beforeError: [ diff --git a/lib/resolve-config.js b/lib/resolve-config.js index d26dbf37..742696b1 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -23,6 +23,7 @@ export default ( CI_PROJECT_URL, CI_PROJECT_PATH, CI_API_V4_URL, + CI_JOB_TOKEN, GL_TOKEN, GITLAB_TOKEN, GL_URL, @@ -52,6 +53,8 @@ export default ( : "https://gitlab.com"); return { gitlabToken: GL_TOKEN || GITLAB_TOKEN, + isJobToken: (!!CI_JOB_TOKEN && (GL_TOKEN || GITLAB_TOKEN) === CI_JOB_TOKEN), + tokenHeader: (!!CI_JOB_TOKEN && (GL_TOKEN || GITLAB_TOKEN) === CI_JOB_TOKEN) ? "JOB-TOKEN" : "PRIVATE-TOKEN", gitlabUrl: defaultedGitlabUrl, gitlabApiUrl: userGitlabUrl && userGitlabApiPathPrefix diff --git a/lib/success.js b/lib/success.js index b2ec1180..7a004329 100644 --- a/lib/success.js +++ b/lib/success.js @@ -17,6 +17,7 @@ export default async (pluginConfig, context) => { } = context; const { gitlabToken, + tokenHeader, gitlabUrl, gitlabApiUrl, successComment, @@ -27,7 +28,7 @@ export default async (pluginConfig, context) => { } = resolveConfig(pluginConfig, context); const { projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); const apiOptions = { - headers: { "PRIVATE-TOKEN": gitlabToken }, + headers: { [tokenHeader]: gitlabToken }, retry: { limit: retryLimit, statusCodes: retryStatusCodes }, }; diff --git a/lib/verify.js b/lib/verify.js index fdfc3b7f..00a402c9 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -6,6 +6,7 @@ import AggregateError from "aggregate-error"; import resolveConfig from "./resolve-config.js"; import getProjectContext from "./get-project-context.js"; import getError from "./get-error.js"; +import urlJoin from "url-join"; const isNonEmptyString = (value) => isString(value) && value.trim(); const isStringOrStringArray = (value) => @@ -30,7 +31,10 @@ export default async (pluginConfig, context) => { options: { repositoryUrl }, logger, } = context; - const { gitlabToken, gitlabUrl, gitlabApiUrl, proxy, ...options } = resolveConfig(pluginConfig, context); + const { gitlabToken, isJobToken, tokenHeader, successCommentCondition, failCommentCondition, gitlabUrl, gitlabApiUrl, proxy, ...options } = resolveConfig( + pluginConfig, + context + ); const { projectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); debug("apiUrl: %o", gitlabApiUrl); @@ -53,6 +57,10 @@ export default async (pluginConfig, context) => { errors.push(getError("ENOGLTOKEN", { repositoryUrl })); } + if (isJobToken && !(failCommentCondition === false) && !(successCommentCondition === false)) { + errors.push(getError("EJOBTOKENCOMMENTCONDITION", { projectPath })) + } + if (gitlabToken && projectPath) { let projectAccess; let groupAccess; @@ -60,23 +68,27 @@ export default async (pluginConfig, context) => { logger.log("Verify GitLab authentication (%s)", gitlabApiUrl); try { - ({ - permissions: { project_access: projectAccess, group_access: groupAccess }, - } = await got - .get(projectApiUrl, { - headers: { "PRIVATE-TOKEN": gitlabToken }, - ...proxy, - }) - .json()); - if ( - context.options.dryRun && - !((projectAccess && projectAccess.access_level >= 10) || (groupAccess && groupAccess.access_level >= 10)) - ) { - errors.push(getError("EGLNOPULLPERMISSION", { projectPath })); - } else if ( - !((projectAccess && projectAccess.access_level >= 30) || (groupAccess && groupAccess.access_level >= 30)) - ) { - errors.push(getError("EGLNOPUSHPERMISSION", { projectPath })); + if (isJobToken) { + await got.get(urlJoin(projectApiUrl, "releases"), { headers: { [tokenHeader]: gitlabToken } }); + } else { + ({ + permissions: { project_access: projectAccess, group_access: groupAccess }, + } = await got + .get(projectApiUrl, { + headers: { [tokenHeader]: gitlabToken }, + ...proxy, + }) + .json()); + if ( + context.options.dryRun && + !((projectAccess && projectAccess.access_level >= 10) || (groupAccess && groupAccess.access_level >= 10)) + ) { + errors.push(getError("EGLNOPULLPERMISSION", { projectPath })); + } else if ( + !((projectAccess && projectAccess.access_level >= 30) || (groupAccess && groupAccess.access_level >= 30)) + ) { + errors.push(getError("EGLNOPUSHPERMISSION", { projectPath })); + } } } catch (error) { if (error.response && error.response.statusCode === 401) { diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index d9c2eb7f..1a69ea88 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -22,5 +22,5 @@ export default function ( : null || '/api/v4', } = {} ) { - return nock(urlJoin(gitlabUrl, gitlabApiPathPrefix), {reqheaders: {'Private-Token': gitlabToken}}); + return nock(urlJoin(gitlabUrl, gitlabApiPathPrefix), {reqheaders: {[gitlabToken === env.CI_JOB_TOKEN ? "Job-Token" : "Private-Token"]: gitlabToken}}); }; diff --git a/test/integration.test.js b/test/integration.test.js index dbfd9f2c..a32307cd 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -112,3 +112,32 @@ test.serial("Verify Github auth and release", async (t) => { t.deepEqual(t.context.log.args[1], ["Published GitLab release: %s", nextRelease.gitTag]); t.true(gitlab.isDone()); }); + +test.serial("Verify GitLab auth and release with Job Token", async (t) => { + const env = { GL_TOKEN: "gitlab_token", CI_JOB_TOKEN: "gitlab_token" }; + const owner = "test_user"; + const repo = "test_repo"; + const options = { repositoryUrl: `https://github.com/${owner}/${repo}.git` }; + const encodedProjectPath = encodeURIComponent(`${owner}/${repo}`); + const nextRelease = { gitHead: "123", gitTag: "v1.0.0", notes: "Test release note body" }; + + const gitlab = authenticate(env) + .get(`/projects/${encodedProjectPath}/releases`) + .reply(200, []) + .post(`/projects/${encodedProjectPath}/releases`, { + tag_name: nextRelease.gitTag, + description: nextRelease.notes, + assets: { + links: [], + }, + }) + .reply(200); + + await t.notThrowsAsync(t.context.m.verifyConditions({ successCommentCondition: false, failCommentCondition: false }, { env, options, logger: t.context.logger })); + const result = await t.context.m.publish({}, { env, options, nextRelease, logger: t.context.logger }); + + t.is(result.url, `https://gitlab.com/${owner}/${repo}/-/releases/${nextRelease.gitTag}`); + t.deepEqual(t.context.log.args[0], ["Verify GitLab authentication (%s)", "https://gitlab.com/api/v4"]); + t.deepEqual(t.context.log.args[1], ["Published GitLab release: %s", nextRelease.gitTag]); + t.true(gitlab.isDone()); +}); diff --git a/test/resolve-config.test.js b/test/resolve-config.test.js index 5f800e78..43123030 100644 --- a/test/resolve-config.test.js +++ b/test/resolve-config.test.js @@ -5,6 +5,8 @@ import resolveConfig from "../lib/resolve-config.js"; const defaultOptions = { gitlabToken: undefined, + isJobToken: false, + tokenHeader: "PRIVATE-TOKEN", gitlabUrl: "https://gitlab.com", gitlabApiUrl: urlJoin("https://gitlab.com", "/api/v4"), assets: undefined, @@ -508,3 +510,23 @@ test("Ignore GitLab CI/CD environment variables if not running on GitLab CI/CD", } ); }); + +test("Use job token when GitLab token equals CI_JOB_TOKEN", (t) => { + const jobToken = "TOKEN" + + t.deepEqual( + resolveConfig( + {}, + { + envCi: { service: "gitlab" }, + env: { GL_TOKEN: jobToken, CI_JOB_TOKEN: jobToken }, + } + ), + { + ...defaultOptions, + gitlabToken: jobToken, + isJobToken: true, + tokenHeader: "JOB-TOKEN", + } + ); +}); diff --git a/test/verify.test.js b/test/verify.test.js index 48f3004f..3773264b 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -988,3 +988,47 @@ test.serial( t.true(gitlab.isDone()); } ); + +test.serial( + 'Throw SemanticReleaseError if "successMessageCondition" and "failMessageCondition" are not explicitly set to `false` when using a job token', + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token", CI_JOB_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}/releases`) + .reply(200, []); + + const { + errors: [error], + } = await t.throwsAsync( + verify( + { }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EJOBTOKENCOMMENTCONDITION"); + t.true(gitlab.isDone()); + } +); + +test.serial( + 'No SemanticReleaseError if "successMessageCondition" and "failMessageCondition" are explicitly set to `false` when using a job token', + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITLAB_TOKEN: "gitlab_token", CI_JOB_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}/releases`) + .reply(200, []); + + await t.notThrowsAsync( + verify( + { successCommentCondition: false, failCommentCondition: false }, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); + } +);