Skip to content

Commit 95748ef

Browse files
feat: add GitLab CI OIDC detector
Add GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from CLOUDSMITH_OIDC_TOKEN (configured via id_tokens in .gitlab-ci.yml, with legacy fallbacks to CI_JOB_JWT_V2 and CI_JOB_JWT) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5eb32e4 commit 95748ef

5 files changed

Lines changed: 151 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111
### Added
1212

1313
- Added GitHub Actions to OIDC credential auto-discovery. When running in GitHub Actions (with `id-token: write` permission), the CLI fetches an OIDC token from the Actions runtime endpoint and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
14+
- Added GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (configured via `id_tokens` in `.gitlab-ci.yml`, with legacy fallbacks to `CI_JOB_JWT_V2` / `CI_JOB_JWT`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1415

1516
## [1.18.0] - 2026-06-09
1617

@@ -27,7 +28,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2728

2829
- `metadata list` filters (`--source-kind`, `--classification`) now send the enum name the v2 API expects instead of an integer, fixing an HTTP 400 on every filtered list. Valid source kinds: `unknown, system, upstream, custom, third_party`; classifications: `unknown, intrinsic, security, provenance, sbom, generic`.
2930

30-
3131
## [1.17.0] - 2026-05-18
3232

3333
### Added

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ pip install cloudsmith-cli[all]
169169

170170
In GitHub Actions, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the Actions runtime when the workflow requests `id-token: write` permission. See the [Cloudsmith GitHub Actions OIDC guide](https://docs.cloudsmith.com/authentication/setup-cloudsmith-to-authenticate-with-oidc-in-github-actions).
171171

172+
#### GitLab CI OIDC Support
173+
174+
In GitLab CI/CD, OIDC credential discovery works out of the box with no extra dependencies. Configure an [`id_tokens`](https://docs.gitlab.com/ci/cloud_services/) entry in your `.gitlab-ci.yml` with an `aud` of `https://api.cloudsmith.io/openid/<your-org>` and expose it as `CLOUDSMITH_OIDC_TOKEN`, and the CLI will pick it up automatically:
175+
176+
```yaml
177+
job:
178+
id_tokens:
179+
CLOUDSMITH_OIDC_TOKEN:
180+
aud: https://api.cloudsmith.io/openid/<your-org>
181+
script:
182+
- cloudsmith push ...
183+
```
184+
185+
See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd).
186+
172187
## Configuration
173188
174189
There are two configuration files used by the CLI:

cloudsmith_cli/core/credentials/oidc/detectors/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .aws import AWSDetector
99
from .base import EnvironmentDetector
1010
from .github_actions import GitHubActionsDetector
11+
from .gitlab_ci import GitLabCIDetector
1112

1213
if TYPE_CHECKING:
1314
from ... import CredentialContext
@@ -16,6 +17,7 @@
1617

1718
_DETECTORS: list[type[EnvironmentDetector]] = [
1819
GitHubActionsDetector,
20+
GitLabCIDetector,
1921
AWSDetector,
2022
]
2123

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""GitLab CI OIDC detector.
3+
4+
Reads an OIDC token from environment variables populated by GitLab's
5+
``id_tokens`` configuration in ``.gitlab-ci.yml``.
6+
7+
References:
8+
https://docs.gitlab.com/ci/cloud_services/
9+
https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
16+
from .base import EnvironmentDetector
17+
18+
19+
class GitLabCIDetector(EnvironmentDetector):
20+
"""Detects GitLab CI and reads an OIDC token from an environment variable.
21+
22+
GitLab requires users to configure ``id_tokens`` in ``.gitlab-ci.yml``.
23+
The token is exposed as an environment variable with a user-chosen name.
24+
We check common conventions in priority order: ``CLOUDSMITH_OIDC_TOKEN``,
25+
``CI_JOB_JWT_V2``, ``CI_JOB_JWT``.
26+
"""
27+
28+
name = "GitLab CI"
29+
30+
TOKEN_ENV_VARS = ["CLOUDSMITH_OIDC_TOKEN", "CI_JOB_JWT_V2", "CI_JOB_JWT"]
31+
32+
def detect(self) -> bool:
33+
if os.environ.get("GITLAB_CI") != "true":
34+
return False
35+
return any(os.environ.get(var) for var in self.TOKEN_ENV_VARS)
36+
37+
def get_token(self) -> str:
38+
for var in self.TOKEN_ENV_VARS:
39+
token = os.environ.get(var)
40+
if token:
41+
return token
42+
raise ValueError(
43+
"GitLab CI detected but no OIDC token found. "
44+
"Configure id_tokens in .gitlab-ci.yml and set one of: "
45+
+ ", ".join(self.TOKEN_ENV_VARS)
46+
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Tests for the GitLab CI OIDC detector."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from cloudsmith_cli.core.credentials.models import CredentialContext
8+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
9+
from cloudsmith_cli.core.credentials.oidc.detectors.gitlab_ci import GitLabCIDetector
10+
11+
12+
@pytest.fixture
13+
def gitlab_env():
14+
env = {
15+
"GITLAB_CI": "true",
16+
"CLOUDSMITH_OIDC_TOKEN": "the-jwt",
17+
}
18+
with mock.patch.dict("os.environ", env, clear=True):
19+
yield env
20+
21+
22+
class TestDetect:
23+
def test_detects_when_gitlab_ci_and_token_present(self, gitlab_env):
24+
detector = GitLabCIDetector(context=CredentialContext())
25+
assert detector.detect() is True
26+
27+
def test_not_detected_when_unset(self):
28+
with mock.patch.dict("os.environ", {}, clear=True):
29+
detector = GitLabCIDetector(context=CredentialContext())
30+
assert detector.detect() is False
31+
32+
def test_not_detected_without_gitlab_ci_flag(self, gitlab_env):
33+
del gitlab_env["GITLAB_CI"]
34+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
35+
detector = GitLabCIDetector(context=CredentialContext())
36+
assert detector.detect() is False
37+
38+
def test_not_detected_when_gitlab_ci_not_true(self, gitlab_env):
39+
gitlab_env["GITLAB_CI"] = "false"
40+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
41+
detector = GitLabCIDetector(context=CredentialContext())
42+
assert detector.detect() is False
43+
44+
def test_not_detected_without_any_token(self, gitlab_env):
45+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
46+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
47+
detector = GitLabCIDetector(context=CredentialContext())
48+
assert detector.detect() is False
49+
50+
def test_detects_with_legacy_ci_job_jwt_v2(self, gitlab_env):
51+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
52+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
53+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
54+
detector = GitLabCIDetector(context=CredentialContext())
55+
assert detector.detect() is True
56+
57+
58+
class TestGetToken:
59+
def test_returns_preferred_token(self, gitlab_env):
60+
detector = GitLabCIDetector(context=CredentialContext())
61+
assert detector.get_token() == "the-jwt"
62+
63+
def test_prefers_cloudsmith_token_over_legacy(self, gitlab_env):
64+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
65+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
66+
detector = GitLabCIDetector(context=CredentialContext())
67+
assert detector.get_token() == "the-jwt"
68+
69+
def test_falls_back_to_legacy_token(self, gitlab_env):
70+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
71+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
72+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
73+
detector = GitLabCIDetector(context=CredentialContext())
74+
assert detector.get_token() == "legacy-jwt"
75+
76+
def test_raises_when_no_token(self, gitlab_env):
77+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
78+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
79+
detector = GitLabCIDetector(context=CredentialContext())
80+
with pytest.raises(ValueError):
81+
detector.get_token()
82+
83+
84+
class TestIntegration:
85+
def test_detect_environment_selects_gitlab_ci(self, gitlab_env):
86+
detector = detect_environment(CredentialContext())
87+
assert isinstance(detector, GitLabCIDetector)

0 commit comments

Comments
 (0)