Skip to content

Commit 8a12a03

Browse files
cloudsmith-iduffyclaudeBartoszBlizniak
authored
feat: add CircleCI OIDC detector (#305)
* feat: add CircleCI OIDC detector Add 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. Works out of the box with no extra dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-trigger CI (flaky CodeQL default-setup auth error) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: BB <55028730+BartoszBlizniak@users.noreply.github.com>
1 parent fc648d2 commit 8a12a03

5 files changed

Lines changed: 122 additions & 0 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

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. Works out of the box with no extra dependencies.
1314
- 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.
1415
- 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.
1516
- 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.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ pip install cloudsmith-cli[all]
165165

166166
**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors.
167167

168+
#### CircleCI OIDC Support
169+
170+
In CircleCI, OIDC credential discovery works out of the box with no extra dependencies — the CLI reads the token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable that CircleCI injects into every job. The Cloudsmith OIDC provider must expect the audience CircleCI mints, which is your CircleCI organization UUID. See the [Cloudsmith CircleCI integration guide](https://docs.cloudsmith.com/integrations/integrating-with-circleci).
171+
168172
#### Azure DevOps OIDC Support
169173

170174
In Azure DevOps Pipelines, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN`. Make sure `SYSTEM_ACCESSTOKEN` is mapped into the step's environment. The Cloudsmith OIDC provider must expect the audience `api://AzureADTokenExchange`, which Azure DevOps always mints (any requested audience is ignored). See the [Cloudsmith Azure DevOps integration guide](https://docs.cloudsmith.com/integrations/integrating-with-azure-devops).

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 .azure_devops import AzureDevOpsDetector
1010
from .base import EnvironmentDetector
11+
from .circleci import CircleCIDetector
1112
from .generic import GenericDetector
1213
from .github_actions import GitHubActionsDetector
1314

@@ -17,6 +18,7 @@
1718
logger = logging.getLogger(__name__)
1819

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

0 commit comments

Comments
 (0)