Skip to content

Commit 1a58cf6

Browse files
feat: add CircleCI OIDC detector
Reads the OIDC token from CIRCLE_OIDC_TOKEN_V2 (preferred) or CIRCLE_OIDC_TOKEN when running in CircleCI, and registers the detector in the auto-discovery chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65f985a commit 1a58cf6

4 files changed

Lines changed: 110 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Added CircleCI to OIDC credential auto-discovery. When running in CircleCI, the CLI reads the OIDC token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable and exchanges it for a Cloudsmith access token.
14+
1115
## [1.17.0] - 2026-05-18
1216

1317
### Added

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77

88
from .aws import AWSDetector
99
from .base import EnvironmentDetector
10+
from .circleci import CircleCIDetector
1011

1112
if TYPE_CHECKING:
1213
from ... import CredentialContext
1314

1415
logger = logging.getLogger(__name__)
1516

1617
_DETECTORS: list[type[EnvironmentDetector]] = [
18+
CircleCIDetector,
1719
AWSDetector,
1820
]
1921

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""CircleCI OIDC detector.
2+
3+
Reads OIDC token from the ``CIRCLE_OIDC_TOKEN_V2`` or ``CIRCLE_OIDC_TOKEN``
4+
environment variables set by CircleCI's OIDC support.
5+
6+
References:
7+
https://circleci.com/docs/guides/permissions-authentication/openid-connect-tokens/
8+
https://docs.cloudsmith.com/integrations/integrating-with-circleci
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
15+
from .base import EnvironmentDetector
16+
17+
18+
class CircleCIDetector(EnvironmentDetector):
19+
"""Detects CircleCI and reads OIDC token from environment variable."""
20+
21+
name = "CircleCI"
22+
23+
def detect(self) -> bool:
24+
return os.environ.get("CIRCLECI") == "true" and bool(
25+
os.environ.get("CIRCLE_OIDC_TOKEN_V2")
26+
or os.environ.get("CIRCLE_OIDC_TOKEN")
27+
)
28+
29+
def get_token(self) -> str:
30+
token = os.environ.get("CIRCLE_OIDC_TOKEN_V2") or os.environ.get(
31+
"CIRCLE_OIDC_TOKEN"
32+
)
33+
if not token:
34+
raise ValueError("CircleCI detected but CIRCLE_OIDC_TOKEN not set")
35+
return token
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Tests for the CircleCI 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.circleci import CircleCIDetector
9+
10+
11+
@pytest.fixture
12+
def circleci_env():
13+
env = {
14+
"CIRCLECI": "true",
15+
"CIRCLE_OIDC_TOKEN_V2": "the-v2-jwt",
16+
"CIRCLE_OIDC_TOKEN": "the-v1-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_circleci_and_v2_token_present(self, circleci_env):
24+
detector = CircleCIDetector(context=CredentialContext())
25+
assert detector.detect() is True
26+
27+
def test_detects_with_only_v1_token(self, circleci_env):
28+
del circleci_env["CIRCLE_OIDC_TOKEN_V2"]
29+
with mock.patch.dict("os.environ", circleci_env, clear=True):
30+
detector = CircleCIDetector(context=CredentialContext())
31+
assert detector.detect() is True
32+
33+
def test_not_detected_when_unset(self):
34+
with mock.patch.dict("os.environ", {}, clear=True):
35+
detector = CircleCIDetector(context=CredentialContext())
36+
assert detector.detect() is False
37+
38+
def test_not_detected_when_circleci_flag_missing(self, circleci_env):
39+
del circleci_env["CIRCLECI"]
40+
with mock.patch.dict("os.environ", circleci_env, clear=True):
41+
detector = CircleCIDetector(context=CredentialContext())
42+
assert detector.detect() is False
43+
44+
def test_not_detected_without_any_token(self, circleci_env):
45+
del circleci_env["CIRCLE_OIDC_TOKEN_V2"]
46+
del circleci_env["CIRCLE_OIDC_TOKEN"]
47+
with mock.patch.dict("os.environ", circleci_env, clear=True):
48+
detector = CircleCIDetector(context=CredentialContext())
49+
assert detector.detect() is False
50+
51+
52+
class TestGetToken:
53+
def test_prefers_v2_token(self, circleci_env):
54+
detector = CircleCIDetector(context=CredentialContext())
55+
assert detector.get_token() == "the-v2-jwt"
56+
57+
def test_falls_back_to_v1_token(self, circleci_env):
58+
del circleci_env["CIRCLE_OIDC_TOKEN_V2"]
59+
with mock.patch.dict("os.environ", circleci_env, clear=True):
60+
detector = CircleCIDetector(context=CredentialContext())
61+
assert detector.get_token() == "the-v1-jwt"
62+
63+
def test_raises_when_no_token(self, circleci_env):
64+
del circleci_env["CIRCLE_OIDC_TOKEN_V2"]
65+
del circleci_env["CIRCLE_OIDC_TOKEN"]
66+
with mock.patch.dict("os.environ", circleci_env, clear=True):
67+
detector = CircleCIDetector(context=CredentialContext())
68+
with pytest.raises(ValueError):
69+
detector.get_token()

0 commit comments

Comments
 (0)