From af314d7bc352db9ccdee9369a50de85795414d67 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 8 Jun 2026 23:43:40 +0100 Subject: [PATCH 1/2] 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) --- CHANGELOG.md | 4 + README.md | 4 + .../credentials/oidc/detectors/__init__.py | 2 + .../credentials/oidc/detectors/circleci.py | 39 ++++++++++ .../core/tests/test_circleci_detector.py | 76 +++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 cloudsmith_cli/core/credentials/oidc/detectors/circleci.py create mode 100644 cloudsmith_cli/core/tests/test_circleci_detector.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8009ae30..e75d1375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- 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. + ## [1.17.0] - 2026-05-18 ### Added diff --git a/README.md b/README.md index c586b108..f465de65 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ pip install cloudsmith-cli[all] **Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors. +#### CircleCI OIDC Support + +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). + ## Configuration There are two configuration files used by the CLI: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index 9b88077c..1fe4e8f0 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -7,6 +7,7 @@ from .aws import AWSDetector from .base import EnvironmentDetector +from .circleci import CircleCIDetector if TYPE_CHECKING: from ... import CredentialContext @@ -14,6 +15,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + CircleCIDetector, AWSDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py b/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py new file mode 100644 index 00000000..acc07f86 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py @@ -0,0 +1,39 @@ +# Copyright 2026 Cloudsmith Ltd +"""CircleCI OIDC detector. + +Reads OIDC token from the ``CIRCLE_OIDC_TOKEN_V2`` or ``CIRCLE_OIDC_TOKEN`` +environment variables set by CircleCI's OIDC support. + +References: + https://circleci.com/docs/guides/permissions-authentication/openid-connect-tokens/ + https://docs.cloudsmith.com/integrations/integrating-with-circleci +""" + +from __future__ import annotations + +import os + +from .base import EnvironmentDetector + + +class CircleCIDetector(EnvironmentDetector): + """Detects CircleCI and reads OIDC token from environment variable.""" + + name = "CircleCI" + + def detect(self) -> bool: + return os.environ.get("CIRCLECI") == "true" and bool( + os.environ.get("CIRCLE_OIDC_TOKEN_V2") + or os.environ.get("CIRCLE_OIDC_TOKEN") + ) + + def get_token(self) -> str: + token = os.environ.get("CIRCLE_OIDC_TOKEN_V2") or os.environ.get( + "CIRCLE_OIDC_TOKEN" + ) + if not token: + raise ValueError( + "CircleCI detected but neither CIRCLE_OIDC_TOKEN_V2 nor " + "CIRCLE_OIDC_TOKEN is set" + ) + return token diff --git a/cloudsmith_cli/core/tests/test_circleci_detector.py b/cloudsmith_cli/core/tests/test_circleci_detector.py new file mode 100644 index 00000000..63f253f7 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_circleci_detector.py @@ -0,0 +1,76 @@ +"""Tests for the CircleCI 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.circleci import CircleCIDetector + + +@pytest.fixture +def circleci_env(): + env = { + "CIRCLECI": "true", + "CIRCLE_OIDC_TOKEN_V2": "the-v2-jwt", + "CIRCLE_OIDC_TOKEN": "the-v1-jwt", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_circleci_and_v2_token_present(self, circleci_env): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_detects_with_only_v1_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_when_circleci_flag_missing(self, circleci_env): + del circleci_env["CIRCLECI"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_any_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + del circleci_env["CIRCLE_OIDC_TOKEN"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_prefers_v2_token(self, circleci_env): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.get_token() == "the-v2-jwt" + + def test_falls_back_to_v1_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.get_token() == "the-v1-jwt" + + def test_raises_when_no_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + del circleci_env["CIRCLE_OIDC_TOKEN"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + with pytest.raises(ValueError, match="CIRCLE_OIDC_TOKEN_V2"): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_circleci(self, circleci_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, CircleCIDetector) From ee548406d2b7702375d71b5f077a735d34f7848c Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Wed, 10 Jun 2026 17:35:37 +0100 Subject: [PATCH 2/2] chore: re-trigger CI (flaky CodeQL default-setup auth error) Co-Authored-By: Claude Fable 5