Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,28 @@ 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 |
| ------------------------------ | ------------------------------------------------------------------------------------------ |
| `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). |

Expand All @@ -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) |
Expand Down
3 changes: 2 additions & 1 deletion lib/fail.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default async (pluginConfig, context) => {
} = context;
const {
gitlabToken,
tokenHeader,
gitlabUrl,
gitlabApiUrl,
failComment,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions lib/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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);

const encodedGitTag = encodeURIComponent(gitTag);
const apiOptions = {
headers: {
"PRIVATE-TOKEN": gitlabToken,
[tokenHeader]: gitlabToken,
},
hooks: {
beforeError: [
Expand Down
10 changes: 7 additions & 3 deletions lib/resolve-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export default (
labels,
assignee,
retryLimit,
useJobToken,
},
{
envCi: { service } = {},
env: {
CI_PROJECT_URL,
CI_PROJECT_PATH,
CI_API_V4_URL,
CI_JOB_TOKEN,
GL_TOKEN,
GITLAB_TOKEN,
GL_URL,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion lib/success.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default async (pluginConfig, context) => {
} = context;
const {
gitlabToken,
tokenHeader,
gitlabUrl,
gitlabApiUrl,
successComment,
Expand All @@ -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 },
};

Expand Down
45 changes: 27 additions & 18 deletions lib/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions test/helpers/mock-gitlab.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import urlJoin from 'url-join';
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'
Expand All @@ -22,5 +23,8 @@ export default function (
: 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 } });
}
31 changes: 31 additions & 0 deletions test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
24 changes: 24 additions & 0 deletions test/resolve-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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,
}
);
});
38 changes: 38 additions & 0 deletions test/verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading