Skip to content

Implement $CI_JOB_TOKEN support in releases #1098

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,14 @@ default token value will be for each remote type.
**Default:** ``{ env = "<envvar name>" }``, where ``<envvar name>`` depends on
:ref:`remote.type <config-remote-type>` as indicated above.

A special case is the GitLab CI environment variable ``"CI_JOB_TOKEN"``. If this variable is set,
it will be used as the job token for the GitLab API. This is useful for accessing the Gitlab Releases
API in the CI environment.

.. warning::
The value of ``"GITLAB_TOKEN"`` takes precedence over ``"CI_JOB_TOKEN"``. If both are set to the
same value, it is assumed to be a job token, not a personal access token.

----

.. _config-remote-type:
Expand Down
57 changes: 52 additions & 5 deletions src/semantic_release/hvcs/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@
from semantic_release.hvcs.util import suppress_not_found

if TYPE_CHECKING: # pragma: no cover
from typing import Any, Callable
from typing import Any, Callable, TypedDict

from gitlab.v4.objects import Project as GitLabProject


log = logging.getLogger(__name__)
class TokenArgs(TypedDict):
private_token: str | None
job_token: str | None


# Globals
Expand All @@ -54,9 +55,9 @@ def __init__(
**_kwargs: Any,
) -> None:
super().__init__(remote_url)
self.token = token
self.project_namespace = f"{self.owner}/{self.repo_name}"
self._project: GitLabProject | None = None
self.is_ci = bool(str(os.getenv("CI", "")).lower() == str(True).lower())

domain_url = self._normalize_url(
hvcs_domain
Expand All @@ -75,7 +76,7 @@ def __init__(
).url.rstrip("/")
)

self._client = gitlab.Gitlab(self.hvcs_domain.url, private_token=self.token)
self._client = self._create_client(token)
self._api_url = parse_url(self._client.api_url)

@property
Expand All @@ -84,6 +85,52 @@ def project(self) -> GitLabProject:
self._project = self._client.projects.get(self.project_namespace)
return self._project

@property
def token(self) -> str:
return [
*filter(
None,
[
self._client.private_token,
self._client.oauth_token,
self._client.job_token,
],
),
"", # default to empty string if no token is found
].pop(0)

@staticmethod
def get_gitlab_server_version() -> tuple[int, ...]:
main_ver_str = os.getenv("CI_SERVER_VERSION", "0.0.0").split("-", maxsplit=1)[0]
try:
return tuple(map(int, main_ver_str.split(".")))
except ValueError:
return 0, 0, 0

def _create_client(self, configured_token: str | None = None) -> gitlab.Gitlab:
"""
Creates a Gitlab client

A configured private token is prioritized over CI_JOB_TOKEN, if both are available
"""
token_args: TokenArgs = {
"private_token": configured_token, # assumed to be a personal access token
"job_token": None,
}

# GitLab Server version 17.2 enabled CI_JOB_TOKEN to write to repository
if self.get_gitlab_server_version()[:2] >= (17, 2):
job_token = os.getenv("CI_JOB_TOKEN", "")

if job_token and job_token == configured_token:
# Swap to only use job token if the configured_token is actually the CI_JOB_TOKEN
token_args = {
"private_token": None,
"job_token": job_token,
}

return gitlab.Gitlab(url=self.hvcs_domain.url, **token_args)

@lru_cache(maxsize=1)
def _get_repository_owner_and_name(self) -> tuple[str, str]:
"""
Expand Down
93 changes: 93 additions & 0 deletions tests/e2e/cmd_version/test_version_gitlab_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

import os
from typing import TYPE_CHECKING
from unittest import mock

import pytest

from semantic_release.cli.commands.main import main

from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD
from tests.util import assert_successful_exit_code

if TYPE_CHECKING:
from unittest.mock import MagicMock

from click.testing import CliRunner
from git.repo import Repo
from requests_mock import Mocker

from tests.e2e.conftest import RetrieveRuntimeContextFn
from tests.fixtures.example_project import UseHvcsFn, UseReleaseNotesTemplateFn


@pytest.mark.parametrize(
"tokens",
[
("gitlab-token", "gitlab-private-token"),
("gitlab-token", "gitlab-token"),
("", "gitlab-token"),
("gitlab-token", None),
("gitlab-token", ""),
(None, "gitlab-private-token"),
(None, None),
],
)
def test_gitlab_release_tokens(
cli_runner: CliRunner,
use_release_notes_template: UseReleaseNotesTemplateFn,
retrieve_runtime_context: RetrieveRuntimeContextFn,
mocked_git_push: MagicMock,
requests_mock: Mocker,
use_gitlab_hvcs: UseHvcsFn,
tokens: tuple[str, str],
repo_w_no_tags_angular_commits: Repo,
) -> None:
"""Verify that gitlab tokens are used correctly."""
# Setup
private_token, job_token = tokens
use_gitlab_hvcs()
requests_mock.register_uri(
"POST",
"https://example.com/api/v4/projects/999/releases",
json={"id": 999},
headers={"Content-Type": "application/json"},
)
requests_mock.register_uri(
"GET",
"https://example.com/api/v4/projects/example_owner%2Fexample_repo",
json={"id": 999},
headers={"Content-Type": "application/json"},
)

env_dict = {}
if private_token is not None:
env_dict["GITLAB_TOKEN"] = private_token
if job_token is not None:
env_dict["CI_JOB_TOKEN"] = job_token
with mock.patch.dict(os.environ, env_dict):
# Act
cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--vcs-release"]
result = cli_runner.invoke(main, cli_cmd[1:])

# Assert
assert_successful_exit_code(result, cli_cmd)
assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag
assert requests_mock.call_count == 2
assert requests_mock.last_request is not None
assert requests_mock.request_history[0].method == "GET"
assert requests_mock.request_history[1].method == "POST"

job_token_header = "JOB-TOKEN"
private_token_header = "PRIVATE-TOKEN"
for request in requests_mock.request_history:
if private_token and private_token != job_token:
assert request._request.headers[private_token_header] == private_token
assert job_token_header not in request._request.headers
elif job_token:
assert request._request.headers[job_token_header] == job_token
assert private_token_header not in request._request.headers
else:
assert private_token_header not in request._request.headers
assert job_token_header not in request._request.headers
25 changes: 24 additions & 1 deletion tests/unit/semantic_release/hvcs/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ def default_gl_client(
"https://special.custom.server",
False,
),
(
# Gather CI_JOB_TOKEN from environment, different from token does not override
{"CI_JOB_TOKEN": "job_token_123"},
None,
f"https://{Gitlab.DEFAULT_DOMAIN}",
False,
),
(
# Gather CI_JOB_TOKEN from environment
{"CI_JOB_TOKEN": "abc123"},
None,
f"https://{Gitlab.DEFAULT_DOMAIN}",
False,
),
(
# Custom domain with path prefix (derives from environment)
{"CI_SERVER_URL": "https://special.custom.server/vcs/"},
Expand Down Expand Up @@ -135,7 +149,16 @@ def test_gitlab_client_init(

# Evaluate (expected -> actual)
assert expected_hvcs_domain == client.hvcs_domain.url
assert token == client.token
if "CI_JOB_TOKEN" in patched_os_environ and (
patched_os_environ["CI_JOB_TOKEN"] == token or not token
):
assert client._client.job_token == patched_os_environ["CI_JOB_TOKEN"]
assert client._client.private_token is None
assert client.token == patched_os_environ["CI_JOB_TOKEN"]
else:
assert client._client.job_token is None
assert client._client.private_token == token
assert token == client.token
assert remote_url == client._remote_url


Expand Down