From 64b990dcd7559f0767380c21e6d3a247280e23cf Mon Sep 17 00:00:00 2001 From: rjoneson Date: Thu, 30 Oct 2025 23:51:26 -0500 Subject: [PATCH 1/8] feat: enable use of job token for gitlab api authentication --- README.md | 16 ++++++++++ lib/definitions/constants.js | 4 +-- lib/definitions/errors.js | 62 ++++++++++++++++++------------------ lib/fail.js | 3 +- lib/publish.js | 4 +-- lib/resolve-config.js | 10 ++++-- lib/success.js | 3 +- lib/verify.js | 45 +++++++++++++++----------- package-lock.json | 5 --- test/helpers/mock-gitlab.js | 36 ++++++++++++--------- test/integration.test.js | 31 ++++++++++++++++++ test/resolve-config.test.js | 24 ++++++++++++++ test/verify.test.js | 38 ++++++++++++++++++++++ 13 files changed, 203 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 840daf53..c28453fd 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,20 @@ Create a [project access token](https://docs.gitlab.com/user/project/settings/pr **Note**: When running with [`dryRun`](https://semantic-release.gitbook.io/semantic-release/usage/configuration#dryrun) only `read_repository` scope is required. +#### Using a CI Job Token + +When running in a GitLab CI/CD environment, you can use the `CI_JOB_TOKEN` for authentication. To enable this, set the `useJobToken` option to `true` in your plugin configuration: + +```json +{ + "plugins": [ + ["@semantic-release/gitlab", { "useJobToken": true }] + ] +} +``` + +> **Important**: When `useJobToken` is enabled, comments on issues and merge requests are automatically disabled. This is due to the limited permissions of the `CI_JOB_TOKEN` which do not allow for these actions. + ### Environment variables | Variable | Description | @@ -65,6 +79,7 @@ Create a [project access token](https://docs.gitlab.com/user/project/settings/pr | `GL_TOKEN` or `GITLAB_TOKEN` | **Required.** The token used to authenticate with GitLab. | | `GL_URL` or `GITLAB_URL` | The GitLab endpoint. | | `GL_PREFIX` or `GITLAB_PREFIX` | The GitLab API prefix. | +| `CI_JOB_TOKEN` | The GitLab CI/CD job token. Used if `useJobToken` is `true`. | | `HTTP_PROXY` or `HTTPS_PROXY` | HTTP or HTTPS proxy to use. | | `NO_PROXY` | Patterns for which the proxy should be ignored. See [details below](#proxy-configuration). | @@ -86,6 +101,7 @@ If you need to bypass the proxy for some hosts, configure the `NO_PROXY` environ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gitlabUrl` | The GitLab endpoint. | `GL_URL` or `GITLAB_URL` environment variable or CI provided environment variables if running on [GitLab CI/CD](https://docs.gitlab.com/ci/) or `https://gitlab.com`. | | `gitlabApiPathPrefix` | The GitLab API prefix. | `GL_PREFIX` or `GITLAB_PREFIX` environment variable or CI provided environment variables if running on [GitLab CI/CD](https://docs.gitlab.com/ci/) or `/api/v4`. | +| `useJobToken` | Set to `true` to use the `CI_JOB_TOKEN` for authentication within a GitLab CI/CD environment. | `false` | | `assets` | An array of files to upload to the release. See [assets](#assets). | - | | `milestones` | An array of milestone titles to associate to the release. See [GitLab Release API](https://docs.gitlab.com/api/releases/#create-a-release). | - | | `successComment` | The comment to add to each Issue and Merge Request resolved by the release. See [successComment](#successComment). | :tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on [GitLab release](gitlab_release_url) | diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js index 18f1ecd3..7f04c590 100644 --- a/lib/definitions/constants.js +++ b/lib/definitions/constants.js @@ -1,3 +1,3 @@ -export const HOME_URL = 'https://github.com/semantic-release/semantic-release'; +export const HOME_URL = "https://github.com/semantic-release/semantic-release"; -export const RELEASE_NAME = 'GitLab release'; +export const RELEASE_NAME = "GitLab release"; diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 51508538..e8a55cc3 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -1,88 +1,88 @@ -import {inspect} from 'util'; +import { inspect } from "util"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const pkg = require("../../package.json"); -const [homepage] = pkg.homepage.split('#'); +const [homepage] = pkg.homepage.split("#"); const linkify = (file) => `${homepage}/blob/master/${file}`; -const stringify = (object) => inspect(object, {breakLength: Number.POSITIVE_INFINITY, depth: 2, maxArrayLength: 5}); +const stringify = (object) => inspect(object, { breakLength: Number.POSITIVE_INFINITY, depth: 2, maxArrayLength: 5 }); export default { - EINVALIDASSETS: ({assets}) => ({ - message: 'Invalid `assets` option.', + EINVALIDASSETS: ({ assets }) => ({ + message: "Invalid `assets` option.", details: `The [assets option](${linkify( - 'README.md#assets' + "README.md#assets" )}) must be an \`Array\` of \`Strings\` or \`Objects\` with a \`path\` property. Your configuration for the \`assets\` option is \`${stringify(assets)}\`.`, }), - EINVALIDFAILTITLE: ({failTitle}) => ({ - message: 'Invalid `failTitle` option.', - details: `The [failTitle option](${linkify('README.md#failtitle')}) if defined, must be a non empty \`String\`. + EINVALIDFAILTITLE: ({ failTitle }) => ({ + message: "Invalid `failTitle` option.", + details: `The [failTitle option](${linkify("README.md#failtitle")}) if defined, must be a non empty \`String\`. Your configuration for the \`failTitle\` option is \`${stringify(failTitle)}\`.`, }), - EINVALIDFAILCOMMENT: ({failComment}) => ({ - message: 'Invalid `failComment` option.', - details: `The [failComment option](${linkify('README.md#failcomment')}) if defined, must be a non empty \`String\`. + EINVALIDFAILCOMMENT: ({ failComment }) => ({ + message: "Invalid `failComment` option.", + details: `The [failComment option](${linkify("README.md#failcomment")}) if defined, must be a non empty \`String\`. Your configuration for the \`failComment\` option is \`${stringify(failComment)}\`.`, }), - EINVALIDLABELS: ({labels}) => ({ - message: 'Invalid `labels` option.', - details: `The [labels option](${linkify('README.md#labels')}) if defined, must be a non empty \`String\`. + EINVALIDLABELS: ({ labels }) => ({ + message: "Invalid `labels` option.", + details: `The [labels option](${linkify("README.md#labels")}) if defined, must be a non empty \`String\`. Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, }), - EINVALIDASSIGNEE: ({assignee}) => ({ - message: 'Invalid `assignee` option.', - details: `The [assignee option](${linkify('README.md#assignee')}) if defined, must be a non empty \`String\`. + EINVALIDASSIGNEE: ({ assignee }) => ({ + message: "Invalid `assignee` option.", + details: `The [assignee option](${linkify("README.md#assignee")}) if defined, must be a non empty \`String\`. Your configuration for the \`assignee\` option is \`${stringify(assignee)}\`.`, }), EINVALIDGITLABURL: () => ({ - message: 'The git repository URL is not a valid GitLab URL.', + message: "The git repository URL is not a valid GitLab URL.", details: `The **semantic-release** \`repositoryUrl\` option must a valid GitLab URL with the format \`/.git\`. 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.`, }), - EINVALIDGLTOKEN: ({projectPath}) => ({ - message: 'Invalid GitLab token.', + EINVALIDGLTOKEN: ({ projectPath }) => ({ + message: "Invalid GitLab token.", details: `The [GitLab token](${linkify( - 'README.md#gitlab-authentication' + "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}. 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.`, }), - EMISSINGREPO: ({projectPath}) => ({ + 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). 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. If you are using [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee) please make sure to configure the \`gitlabUrl\` and \`gitlabApiPathPrefix\` [options](${linkify( - 'README.md#options' + "README.md#options" )}).`, }), - EGLNOPUSHPERMISSION: ({projectPath}) => ({ + EGLNOPUSHPERMISSION: ({ projectPath }) => ({ message: `The GitLab token doesn't allow to push on the repository ${projectPath}.`, details: `The user associated with the [GitLab token](${linkify( - 'README.md#gitlab-authentication' + "README.md#gitlab-authentication" )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allows to 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}) => ({ + EGLNOPULLPERMISSION: ({ projectPath }) => ({ message: `The GitLab token doesn't allow to pull from the repository ${projectPath}.`, details: `The user associated with the [GitLab token](${linkify( - 'README.md#gitlab-authentication' + "README.md#gitlab-authentication" )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allow pull from 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}.`, }), - ENOGLTOKEN: ({repositoryUrl}) => ({ - message: 'No GitLab token specified.', + ENOGLTOKEN: ({ repositoryUrl }) => ({ + message: "No GitLab token specified.", details: `A [GitLab personal access token](${linkify( - 'README.md#gitlab-authentication' + "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}.`, 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 39497268..a86c4ac9 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); @@ -30,7 +30,7 @@ export default async (pluginConfig, context) => { const encodedGitTag = encodeURIComponent(gitTag); const apiOptions = { headers: { - "PRIVATE-TOKEN": gitlabToken, + [tokenHeader]: gitlabToken, }, hooks: { beforeError: [ diff --git a/lib/resolve-config.js b/lib/resolve-config.js index d26dbf37..3bb87627 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -16,6 +16,7 @@ export default ( labels, assignee, retryLimit, + useJobToken, }, { envCi: { service } = {}, @@ -23,6 +24,7 @@ export default ( CI_PROJECT_URL, CI_PROJECT_PATH, CI_API_V4_URL, + CI_JOB_TOKEN, GL_TOKEN, GITLAB_TOKEN, GL_URL, @@ -51,7 +53,9 @@ export default ( ? CI_PROJECT_URL.replace(new RegExp(`/${CI_PROJECT_PATH}$`), "") : "https://gitlab.com"); return { - gitlabToken: GL_TOKEN || GITLAB_TOKEN, + gitlabToken: useJobToken ? CI_JOB_TOKEN : GL_TOKEN || GITLAB_TOKEN, + tokenHeader: useJobToken ? "JOB-TOKEN" : "PRIVATE-TOKEN", + useJobToken, gitlabUrl: defaultedGitlabUrl, gitlabApiUrl: userGitlabUrl && userGitlabApiPathPrefix @@ -62,11 +66,11 @@ export default ( assets: assets ? castArray(assets) : assets, milestones: milestones ? castArray(milestones) : milestones, successComment, - successCommentCondition, + successCommentCondition: useJobToken ? false : successCommentCondition, proxy: getProxyConfiguration(defaultedGitlabUrl, HTTP_PROXY, HTTPS_PROXY, NO_PROXY), failTitle: isNil(failTitle) ? "The automated release is failing 🚨" : failTitle, failComment, - failCommentCondition, + failCommentCondition: useJobToken ? false : failCommentCondition, labels: isNil(labels) ? "semantic-release" : labels === false ? false : labels, assignee, retryLimit: retryLimit ?? DEFAULT_RETRY_LIMIT, 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..bade808f 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, gitlabUrl, gitlabApiUrl, tokenHeader, useJobToken, proxy, ...options } = resolveConfig( + pluginConfig, + context + ); const { projectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); debug("apiUrl: %o", gitlabApiUrl); @@ -60,23 +64,28 @@ 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 (useJobToken) { + logger.log("Using Job Token for authentication. Some functionality may be disabled."); + 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/package-lock.json b/package-lock.json index 05e8843e..9e0ae39d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -325,7 +325,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -954,7 +953,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3688,7 +3686,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6502,7 +6499,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7598,7 +7594,6 @@ "integrity": "sha512-0OCYLm0AfVilNGukM+w0C4aptITfuW1Mhvmz8LQliLeYbPOTFRCIJzoltWWx/F5zVFe6np9eNatBUHdAvMFeZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index d9c2eb7f..dcc7587b 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -1,26 +1,32 @@ -import nock from 'nock'; -import urlJoin from 'url-join'; +import nock from "nock"; +import urlJoin from "url-join"; /** - * Retun a `nock` object setup to respond to a GitLab authentication request. Other expectation and responses can be chained. + * Return a `nock` object setup to respond to a GitLab authentication request. Other expectation and responses can be chained. * * @param {Object} [env={}] Environment variables. - * @param {String} [gitlabToken=env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN'] The github token to return in the authentication response. - * @param {String} [gitlabUrl=env.GL_URL || env.GITLAB_URL || 'https://api.github.com'] The url on which to intercept http requests. - * @param {String} [gitlabApiPathPrefix=env.GL_PREFIX || env.GITLAB_PREFIX || ''] The GitHub Enterprise API prefix. - * @return {Object} A `nock` object ready to respond to a github authentication request. + * @param {Object} [options={}] Options. + * @param {boolean} [options.useJobToken=false] Whether to use a CI_JOB_TOKEN. + * @param {String} [options.gitlabToken=env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN'] The GitLab token to use for authentication. + * @param {String} [options.gitlabUrl=env.GL_URL || env.GITLAB_URL || 'https://gitlab.com'] The url on which to intercept http requests. + * @param {String} [options.gitlabApiPathPrefix=env.GL_PREFIX || env.GITLAB_PREFIX || '/api/v4'] The GitLab API prefix. + * @return {Object} A `nock` object ready to respond to a GitLab authentication request. */ export default function ( env = {}, { - gitlabToken = env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN', - gitlabUrl = env.GL_URL || env.GITLAB_URL || 'https://gitlab.com', - gitlabApiPathPrefix = typeof env.GL_PREFIX === 'string' + useJobToken = false, + gitlabToken = env.GL_TOKEN || env.GITLAB_TOKEN || "GL_TOKEN", + gitlabUrl = env.GL_URL || env.GITLAB_URL || "https://gitlab.com", + gitlabApiPathPrefix = typeof env.GL_PREFIX === "string" ? env.GL_PREFIX - : null || typeof env.GITLAB_PREFIX === 'string' - ? env.GITLAB_PREFIX - : null || '/api/v4', + : null || typeof env.GITLAB_PREFIX === "string" + ? env.GITLAB_PREFIX + : null || "/api/v4", } = {} ) { - return nock(urlJoin(gitlabUrl, gitlabApiPathPrefix), {reqheaders: {'Private-Token': gitlabToken}}); -}; + const tokenHeader = useJobToken ? "JOB-TOKEN" : "Private-Token"; + const token = useJobToken ? env.CI_JOB_TOKEN : gitlabToken; + + return nock(urlJoin(gitlabUrl, gitlabApiPathPrefix), { reqheaders: { [tokenHeader]: token } }); +} diff --git a/test/integration.test.js b/test/integration.test.js index dbfd9f2c..4b8863f3 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -112,3 +112,34 @@ 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 = { CI_JOB_TOKEN: "job_token" }; + const owner = "test_user"; + const repo = "test_repo"; + const options = { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }; + const encodedProjectPath = encodeURIComponent(`${owner}/${repo}`); + const nextRelease = { gitHead: "123", gitTag: "v1.0.0", notes: "Test release note body" }; + const pluginConfig = { useJobToken: true }; + + const gitlab = authenticate(env, { useJobToken: true }) + .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(pluginConfig, { env, options, logger: t.context.logger })); + const result = await t.context.m.publish(pluginConfig, { 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], ["Using Job Token for authentication. Some functionality may be disabled."]); + t.deepEqual(t.context.log.args[2], ["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..931cf677 100644 --- a/test/resolve-config.test.js +++ b/test/resolve-config.test.js @@ -16,6 +16,8 @@ const defaultOptions = { failCommentCondition: undefined, labels: "semantic-release", assignee: undefined, + tokenHeader: "PRIVATE-TOKEN", + useJobToken: undefined, proxy: {}, retryLimit: 3, retryStatusCodes: [408, 413, 422, 429, 500, 502, 503, 504, 521, 522, 524], @@ -508,3 +510,25 @@ test("Ignore GitLab CI/CD environment variables if not running on GitLab CI/CD", } ); }); + +test("Set token header to JOB-TOKEN when useJobToken is set to true", (t) => { + const jobToken = "TOKEN"; + + t.deepEqual( + resolveConfig( + { useJobToken: true }, + { + envCi: { service: "gitlab" }, + env: { CI_JOB_TOKEN: jobToken }, + } + ), + { + ...defaultOptions, + gitlabToken: jobToken, + useJobToken: true, + tokenHeader: "JOB-TOKEN", + successCommentCondition: false, + failCommentCondition: false, + } + ); +}); diff --git a/test/verify.test.js b/test/verify.test.js index 48f3004f..b0b1f3b9 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -86,6 +86,44 @@ test.serial("Verify token and repository access (group_access 40)", async (t) => t.true(gitlab.isDone()); }); +test.serial("Verify CI_JOB_TOKEN and repository access", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { CI_JOB_TOKEN: "job_token" }; + const gitlab = authenticate(env, { useJobToken: true }).get(`/projects/${owner}%2F${repo}/releases`).reply(200); + + await t.notThrowsAsync( + verify( + { useJobToken: true }, + { env, options: { repositoryUrl: `git+https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + + t.true(gitlab.isDone()); + t.deepEqual(t.context.log.args[1], ["Using Job Token for authentication. Some functionality may be disabled."]); +}); + +test.serial("Throw SemanticReleaseError for invalid CI_JOB_TOKEN", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { CI_JOB_TOKEN: "job_token" }; + const gitlab = authenticate(env, { useJobToken: true }).get(`/projects/${owner}%2F${repo}/releases`).reply(401); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + { useJobToken: true }, + { env, options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDGLTOKEN"); + t.true(gitlab.isDone()); +}); + test.serial("Verify token and repository access and custom URL with prefix", async (t) => { const owner = "test_user"; const repo = "test_repo"; From b30188906ae3a42a871e503c7b19951d5bb94400 Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:24:39 -0600 Subject: [PATCH 2/8] revert: revert formatting changes --- lib/definitions/constants.js | 4 +-- lib/definitions/errors.js | 62 ++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js index 7f04c590..18f1ecd3 100644 --- a/lib/definitions/constants.js +++ b/lib/definitions/constants.js @@ -1,3 +1,3 @@ -export const HOME_URL = "https://github.com/semantic-release/semantic-release"; +export const HOME_URL = 'https://github.com/semantic-release/semantic-release'; -export const RELEASE_NAME = "GitLab release"; +export const RELEASE_NAME = 'GitLab release'; diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index e8a55cc3..07c26577 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -1,88 +1,88 @@ -import { inspect } from "util"; +import { inspect } from 'util'; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const pkg = require("../../package.json"); -const [homepage] = pkg.homepage.split("#"); +const [homepage] = pkg.homepage.split('#'); const linkify = (file) => `${homepage}/blob/master/${file}`; -const stringify = (object) => inspect(object, { breakLength: Number.POSITIVE_INFINITY, depth: 2, maxArrayLength: 5 }); +const stringify = (object) => inspect(object, {breakLength: Number.POSITIVE_INFINITY, depth: 2, maxArrayLength: 5}); export default { - EINVALIDASSETS: ({ assets }) => ({ - message: "Invalid `assets` option.", + EINVALIDASSETS: ({assets}) => ({ + message: 'Invalid `assets` option.', details: `The [assets option](${linkify( - "README.md#assets" + 'README.md#assets' )}) must be an \`Array\` of \`Strings\` or \`Objects\` with a \`path\` property. Your configuration for the \`assets\` option is \`${stringify(assets)}\`.`, }), - EINVALIDFAILTITLE: ({ failTitle }) => ({ - message: "Invalid `failTitle` option.", - details: `The [failTitle option](${linkify("README.md#failtitle")}) if defined, must be a non empty \`String\`. + EINVALIDFAILTITLE: ({failTitle}) => ({ + message: 'Invalid `failTitle` option.', + details: `The [failTitle option](${linkify('README.md#failtitle')}) if defined, must be a non empty \`String\`. Your configuration for the \`failTitle\` option is \`${stringify(failTitle)}\`.`, }), - EINVALIDFAILCOMMENT: ({ failComment }) => ({ - message: "Invalid `failComment` option.", - details: `The [failComment option](${linkify("README.md#failcomment")}) if defined, must be a non empty \`String\`. + EINVALIDFAILCOMMENT: ({failComment}) => ({ + message: 'Invalid `failComment` option.', + details: `The [failComment option](${linkify('README.md#failcomment')}) if defined, must be a non empty \`String\`. Your configuration for the \`failComment\` option is \`${stringify(failComment)}\`.`, }), - EINVALIDLABELS: ({ labels }) => ({ - message: "Invalid `labels` option.", - details: `The [labels option](${linkify("README.md#labels")}) if defined, must be a non empty \`String\`. + EINVALIDLABELS: ({labels}) => ({ + message: 'Invalid `labels` option.', + details: `The [labels option](${linkify('README.md#labels')}) if defined, must be a non empty \`String\`. Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, }), - EINVALIDASSIGNEE: ({ assignee }) => ({ - message: "Invalid `assignee` option.", - details: `The [assignee option](${linkify("README.md#assignee")}) if defined, must be a non empty \`String\`. + EINVALIDASSIGNEE: ({ ssignee}) => ({ + message: 'Invalid `assignee` option.', + details: `The [assignee option](${linkify('README.md#assignee')}) if defined, must be a non empty \`String\`. Your configuration for the \`assignee\` option is \`${stringify(assignee)}\`.`, }), EINVALIDGITLABURL: () => ({ - message: "The git repository URL is not a valid GitLab URL.", + message: 'The git repository URL is not a valid GitLab URL.', details: `The **semantic-release** \`repositoryUrl\` option must a valid GitLab URL with the format \`/.git\`. 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.`, }), - EINVALIDGLTOKEN: ({ projectPath }) => ({ - message: "Invalid GitLab token.", + EINVALIDGLTOKEN: ({projectPath}) => ({ + message: 'Invalid GitLab token.', details: `The [GitLab token](${linkify( - "README.md#gitlab-authentication" + '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}. 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.`, }), - EMISSINGREPO: ({ projectPath }) => ({ + 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). 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. If you are using [GitLab Enterprise Edition](https://about.gitlab.com/gitlab-ee) please make sure to configure the \`gitlabUrl\` and \`gitlabApiPathPrefix\` [options](${linkify( - "README.md#options" + 'README.md#options' )}).`, }), - EGLNOPUSHPERMISSION: ({ projectPath }) => ({ + EGLNOPUSHPERMISSION: ({projectPath}) => ({ message: `The GitLab token doesn't allow to push on the repository ${projectPath}.`, details: `The user associated with the [GitLab token](${linkify( - "README.md#gitlab-authentication" + 'README.md#gitlab-authentication' )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allows to 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 }) => ({ + EGLNOPULLPERMISSION: ({projectPath}) => ({ message: `The GitLab token doesn't allow to pull from the repository ${projectPath}.`, details: `The user associated with the [GitLab token](${linkify( - "README.md#gitlab-authentication" + 'README.md#gitlab-authentication' )}) configured in the \`GL_TOKEN\` or \`GITLAB_TOKEN\` environment variable must allow pull from 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}.`, }), - ENOGLTOKEN: ({ repositoryUrl }) => ({ - message: "No GitLab token specified.", + ENOGLTOKEN: ({repositoryUrl}) => ({ + message: 'No GitLab token specified.', details: `A [GitLab personal access token](${linkify( - "README.md#gitlab-authentication" + '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}.`, From dd02e445bcc3bfff14a85ceed0187f026c44d280 Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:27:53 -0600 Subject: [PATCH 3/8] revert: revert formatting changes --- lib/definitions/errors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 07c26577..51508538 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -1,4 +1,4 @@ -import { inspect } from 'util'; +import {inspect} from 'util'; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); @@ -33,7 +33,7 @@ Your configuration for the \`failComment\` option is \`${stringify(failComment)} Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, }), - EINVALIDASSIGNEE: ({ ssignee}) => ({ + EINVALIDASSIGNEE: ({assignee}) => ({ message: 'Invalid `assignee` option.', details: `The [assignee option](${linkify('README.md#assignee')}) if defined, must be a non empty \`String\`. From 3450977e1952eeae12abdace61134971ff18748d Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:31:09 -0600 Subject: [PATCH 4/8] revert: revert formatting changes --- test/helpers/mock-gitlab.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index dcc7587b..b49b2a02 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -1,5 +1,5 @@ -import nock from "nock"; -import urlJoin from "url-join"; +import nock from 'nock'; +import urlJoin from 'url-join'; /** * Return a `nock` object setup to respond to a GitLab authentication request. Other expectation and responses can be chained. @@ -16,13 +16,13 @@ export default function ( env = {}, { useJobToken = false, - gitlabToken = env.GL_TOKEN || env.GITLAB_TOKEN || "GL_TOKEN", - gitlabUrl = env.GL_URL || env.GITLAB_URL || "https://gitlab.com", - gitlabApiPathPrefix = typeof env.GL_PREFIX === "string" + gitlabToken = env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN', + gitlabUrl = env.GL_URL || env.GITLAB_URL || 'https://gitlab.com', + gitlabApiPathPrefix = typeof env.GL_PREFIX === 'string' ? env.GL_PREFIX : null || typeof env.GITLAB_PREFIX === "string" ? env.GITLAB_PREFIX - : null || "/api/v4", + : null || '/api/v4', } = {} ) { const tokenHeader = useJobToken ? "JOB-TOKEN" : "Private-Token"; From fdd780240b31bae949f9a5ad7ac411cea23387c9 Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:32:15 -0600 Subject: [PATCH 5/8] revert: revert formatting changes --- test/helpers/mock-gitlab.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index b49b2a02..95df54c1 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -20,7 +20,7 @@ export default function ( gitlabUrl = env.GL_URL || env.GITLAB_URL || 'https://gitlab.com', gitlabApiPathPrefix = typeof env.GL_PREFIX === 'string' ? env.GL_PREFIX - : null || typeof env.GITLAB_PREFIX === "string" + : null || typeof env.GITLAB_PREFIX === 'string' ? env.GITLAB_PREFIX : null || '/api/v4', } = {} From 149634a1d4b084e6646217427210cf5c1fb152c1 Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:33:25 -0600 Subject: [PATCH 6/8] revert: revert formatting changes --- test/helpers/mock-gitlab.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index 95df54c1..5d2a14f9 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -21,8 +21,8 @@ export default function ( gitlabApiPathPrefix = typeof env.GL_PREFIX === 'string' ? env.GL_PREFIX : null || typeof env.GITLAB_PREFIX === 'string' - ? env.GITLAB_PREFIX - : null || '/api/v4', + ? env.GITLAB_PREFIX + : null || '/api/v4', } = {} ) { const tokenHeader = useJobToken ? "JOB-TOKEN" : "Private-Token"; From 9dfbabcc0029bd5cda23a09b685ab944d118f4f4 Mon Sep 17 00:00:00 2001 From: rjoneson Date: Sat, 20 Dec 2025 11:35:02 -0600 Subject: [PATCH 7/8] revert: revert formatting changes --- test/helpers/mock-gitlab.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/helpers/mock-gitlab.js b/test/helpers/mock-gitlab.js index 5d2a14f9..fbf41327 100644 --- a/test/helpers/mock-gitlab.js +++ b/test/helpers/mock-gitlab.js @@ -2,15 +2,13 @@ import nock from 'nock'; import urlJoin from 'url-join'; /** - * Return a `nock` object setup to respond to a GitLab authentication request. Other expectation and responses can be chained. + * Retun a `nock` object setup to respond to a GitLab authentication request. Other expectation and responses can be chained. * * @param {Object} [env={}] Environment variables. - * @param {Object} [options={}] Options. - * @param {boolean} [options.useJobToken=false] Whether to use a CI_JOB_TOKEN. - * @param {String} [options.gitlabToken=env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN'] The GitLab token to use for authentication. - * @param {String} [options.gitlabUrl=env.GL_URL || env.GITLAB_URL || 'https://gitlab.com'] The url on which to intercept http requests. - * @param {String} [options.gitlabApiPathPrefix=env.GL_PREFIX || env.GITLAB_PREFIX || '/api/v4'] The GitLab API prefix. - * @return {Object} A `nock` object ready to respond to a GitLab authentication request. + * @param {String} [gitlabToken=env.GL_TOKEN || env.GITLAB_TOKEN || 'GL_TOKEN'] The github token to return in the authentication response. + * @param {String} [gitlabUrl=env.GL_URL || env.GITLAB_URL || 'https://api.github.com'] The url on which to intercept http requests. + * @param {String} [gitlabApiPathPrefix=env.GL_PREFIX || env.GITLAB_PREFIX || ''] The GitHub Enterprise API prefix. + * @return {Object} A `nock` object ready to respond to a github authentication request. */ export default function ( env = {}, From 9160aaab3669e1c7375b5c9485678691e38efc8c Mon Sep 17 00:00:00 2001 From: rjoneson Date: Mon, 22 Dec 2025 13:15:53 -0600 Subject: [PATCH 8/8] feat: add logging for comment properties with useJobToken --- lib/fail.js | 7 ++++++- lib/success.js | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/fail.js b/lib/fail.js index ce7221b6..3ce29c1e 100644 --- a/lib/fail.js +++ b/lib/fail.js @@ -26,6 +26,7 @@ export default async (pluginConfig, context) => { assignee, retryLimit, retryStatusCodes, + useJobToken, } = resolveConfig(pluginConfig, context); const { encodedProjectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); @@ -42,7 +43,11 @@ export default async (pluginConfig, context) => { logger.error(`Failure reporting should be disabled via 'failCommentCondition'. Using 'false' for 'failComment' or 'failTitle' is deprecated and will be removed in a future major version.`); } else if (failCommentCondition === false) { - logger.log("Skip issue creation."); + logger.log( + "Skip issue creation." + useJobToken + ? " Setting 'failComment' or 'failTitle' has no effect when 'useJobToken' is set." + : "" + ); } else { const encodedFailTitle = encodeURIComponent(failTitle); const description = failComment ? template(failComment)({ branch, errors }) : getFailComment(branch, errors); diff --git a/lib/success.js b/lib/success.js index 7a004329..1c3ed3bb 100644 --- a/lib/success.js +++ b/lib/success.js @@ -25,6 +25,7 @@ export default async (pluginConfig, context) => { proxy, retryLimit, retryStatusCodes, + useJobToken, } = resolveConfig(pluginConfig, context); const { projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); const apiOptions = { @@ -37,7 +38,11 @@ export default async (pluginConfig, context) => { logger.error(`Issue and pull request comments should be disabled via 'successCommentCondition'. Using 'false' for 'successComment' is deprecated and will be removed in a future major version.`); } else if (successCommentCondition === false) { - logger.log("Skip commenting on issues and pull requests."); + logger.log( + "Skip commenting on issues and pull requests." + useJobToken + ? " Setting 'successComment' has no effect when 'useJobToken' is set." + : "" + ); } else { const releaseInfos = releases.filter((release) => Boolean(release.name)); try {