Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Added Azure DevOps to OIDC credential auto-discovery. When running in an Azure DevOps pipeline, the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN` and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
- 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.
- Added a generic fallback to OIDC credential auto-discovery. When no dedicated environment is detected, the CLI reads an OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (useful for Jenkins or any custom CI/CD) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
- 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`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.

## [1.18.0] - 2026-06-09

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ In Azure DevOps Pipelines, OIDC credential discovery works out of the box with n

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).

#### GitLab CI OIDC Support

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:

```yaml
job:
id_tokens:
CLOUDSMITH_OIDC_TOKEN:
aud: https://api.cloudsmith.io/openid/<your-org>
script:
- cloudsmith push ...
```

See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd).

#### Generic OIDC Support (Jenkins, custom CI/CD)

As a fallback for environments without a dedicated detector (for example Jenkins with the [credentials binding plugin](https://plugins.jenkins.io/credentials-binding/), or any custom CI/CD system), set the `CLOUDSMITH_OIDC_TOKEN` environment variable to an OIDC JWT and the CLI will exchange it for a Cloudsmith access token. This detector runs last, so a dedicated environment is always preferred when present. See the [Cloudsmith Jenkins OIDC guide](https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc).
Expand Down
2 changes: 2 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .circleci import CircleCIDetector
from .generic import GenericDetector
from .github_actions import GitHubActionsDetector
from .gitlab_ci import GitLabCIDetector

if TYPE_CHECKING:
from ... import CredentialContext
Expand All @@ -23,6 +24,7 @@
AzureDevOpsDetector,
GitHubActionsDetector,
BitbucketPipelinesDetector,
GitLabCIDetector,
AWSDetector,
GenericDetector,
]
Expand Down
48 changes: 48 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright 2026 Cloudsmith Ltd
"""GitLab CI OIDC detector.
Comment thread
Copilot marked this conversation as resolved.

Reads an OIDC token from environment variables populated by GitLab's
``id_tokens`` configuration in ``.gitlab-ci.yml``.

References:
https://docs.gitlab.com/ci/cloud_services/
https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd
"""

from __future__ import annotations

import os

from .base import EnvironmentDetector


class GitLabCIDetector(EnvironmentDetector):
"""Detects GitLab CI and reads an OIDC token from an environment variable.

GitLab requires users to configure ``id_tokens`` in ``.gitlab-ci.yml``,
minting a token with ``aud`` set to the Cloudsmith OIDC endpoint and
exposing it as ``CLOUDSMITH_OIDC_TOKEN``. The legacy ``CI_JOB_JWT``/
``CI_JOB_JWT_V2`` variables are deliberately not consulted: they were
removed in GitLab 17.0, carry the GitLab instance URL as their audience
(not the Cloudsmith audience the token exchange validates), and were
auto-injected into every job on older instances.
"""

name = "GitLab CI"

TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN"

def detect(self) -> bool:
if os.environ.get("GITLAB_CI") != "true":
return False
return bool(os.environ.get(self.TOKEN_ENV_VAR))

def get_token(self) -> str:
token = os.environ.get(self.TOKEN_ENV_VAR)
if token:
return token
raise ValueError(
"GitLab CI detected but no OIDC token found. "
"Configure id_tokens in .gitlab-ci.yml and expose it as "
+ self.TOKEN_ENV_VAR
)
74 changes: 74 additions & 0 deletions cloudsmith_cli/core/tests/test_gitlab_ci_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for the GitLab CI OIDC detector."""

from unittest import mock

import pytest

from cloudsmith_cli.core.credentials.models import CredentialContext
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
from cloudsmith_cli.core.credentials.oidc.detectors.gitlab_ci import GitLabCIDetector


@pytest.fixture
def gitlab_env():
env = {
"GITLAB_CI": "true",
"CLOUDSMITH_OIDC_TOKEN": "the-jwt",
}
with mock.patch.dict("os.environ", env, clear=True):
yield env


class TestDetect:
def test_detects_when_gitlab_ci_and_token_present(self, gitlab_env):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is True

def test_not_detected_when_unset(self):
with mock.patch.dict("os.environ", {}, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_without_gitlab_ci_flag(self, gitlab_env):
del gitlab_env["GITLAB_CI"]
with mock.patch.dict("os.environ", gitlab_env, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_when_gitlab_ci_not_true(self, gitlab_env):
gitlab_env["GITLAB_CI"] = "false"
with mock.patch.dict("os.environ", gitlab_env, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_without_any_token(self, gitlab_env):
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
with mock.patch.dict("os.environ", gitlab_env, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_with_legacy_ci_job_jwt(self, gitlab_env):
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
with mock.patch.dict("os.environ", gitlab_env, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.detect() is False


class TestGetToken:
def test_returns_token(self, gitlab_env):
detector = GitLabCIDetector(context=CredentialContext())
assert detector.get_token() == "the-jwt"

def test_raises_when_no_token(self, gitlab_env):
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
with mock.patch.dict("os.environ", gitlab_env, clear=True):
detector = GitLabCIDetector(context=CredentialContext())
with pytest.raises(ValueError):
detector.get_token()


class TestIntegration:
def test_detect_environment_selects_gitlab_ci(self, gitlab_env):
detector = detect_environment(CredentialContext())
assert isinstance(detector, GitLabCIDetector)
Loading