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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

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

## [1.18.0] - 2026-06-09

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

#### GitHub Actions OIDC Support

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

## Configuration

There are two configuration files used by the CLI:
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 @@ -7,13 +7,15 @@

from .aws import AWSDetector
from .base import EnvironmentDetector
from .github_actions import GitHubActionsDetector

if TYPE_CHECKING:
from ... import CredentialContext

logger = logging.getLogger(__name__)

_DETECTORS: list[type[EnvironmentDetector]] = [
GitHubActionsDetector,
AWSDetector,
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright 2026 Cloudsmith Ltd
"""GitHub Actions OIDC detector.

Fetches an OIDC token via the Actions runtime HTTP endpoint, using the
``ACTIONS_ID_TOKEN_REQUEST_URL`` and ``ACTIONS_ID_TOKEN_REQUEST_TOKEN``
variables exposed when a workflow requests ``id-token: write`` permission.

References:
https://docs.github.com/en/actions/reference/security/oidc
https://docs.cloudsmith.com/authentication/setup-cloudsmith-to-authenticate-with-oidc-in-github-actions
"""

from __future__ import annotations

import os
from urllib.parse import quote

from ....rest import create_requests_session as create_session
from .base import EnvironmentDetector

DEFAULT_AUDIENCE = "cloudsmith"


class GitHubActionsDetector(EnvironmentDetector):
"""Detects GitHub Actions and fetches an OIDC token via HTTP request."""

name = "GitHub Actions"

def detect(self) -> bool:
return (
os.environ.get("GITHUB_ACTIONS") == "true"
and bool(os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL"))
and bool(os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN"))
)

def get_token(self) -> str:
request_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]
request_token = os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]

audience = self.context.oidc_audience or DEFAULT_AUDIENCE
separator = "&" if "?" in request_url else "?"
url = f"{request_url}{separator}audience={quote(audience, safe='')}"

session = self.context.session or create_session()
try:
response = session.get(
url,
headers={
"Authorization": f"Bearer {request_token}",
"Accept": "application/json; api-version=2.0",
},
timeout=30,
)
response.raise_for_status()

data = response.json()
token = data.get("value")
if not token:
raise ValueError("GitHub Actions OIDC response did not contain a token")
return token
finally:
if not self.context.session:
session.close()
116 changes: 116 additions & 0 deletions cloudsmith_cli/core/tests/test_github_actions_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tests for the GitHub Actions 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.github_actions import (
GitHubActionsDetector,
)


@pytest.fixture
def github_env():
env = {
"GITHUB_ACTIONS": "true",
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://token.actions.example/req",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token",
}
with mock.patch.dict("os.environ", env, clear=True):
yield env


class TestDetect:
def test_detects_when_all_env_vars_present(self, github_env):
detector = GitHubActionsDetector(context=CredentialContext())
assert detector.detect() is True

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

def test_not_detected_without_request_url(self, github_env):
del github_env["ACTIONS_ID_TOKEN_REQUEST_URL"]
with mock.patch.dict("os.environ", github_env, clear=True):
detector = GitHubActionsDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_without_request_token(self, github_env):
del github_env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
with mock.patch.dict("os.environ", github_env, clear=True):
detector = GitHubActionsDetector(context=CredentialContext())
assert detector.detect() is False


class TestGetToken:
def _mock_session(self, json_data, status_ok=True):
response = mock.Mock()
response.json.return_value = json_data
response.raise_for_status = mock.Mock()
session = mock.Mock()
session.get.return_value = response
return session, response

def test_returns_token_from_response(self, github_env):
session, _ = self._mock_session({"value": "the-jwt"})
context = CredentialContext(session=session)
detector = GitHubActionsDetector(context=context)

token = detector.get_token()

assert token == "the-jwt"

def test_requests_url_with_audience_and_auth_header(self, github_env):
session, response = self._mock_session({"value": "the-jwt"})
context = CredentialContext(session=session)
detector = GitHubActionsDetector(context=context)

detector.get_token()

called_url = session.get.call_args[0][0]
assert called_url.startswith("https://token.actions.example/req?audience=")
assert "cloudsmith" in called_url
headers = session.get.call_args[1]["headers"]
assert headers["Authorization"] == "Bearer request-token"
response.raise_for_status.assert_called_once()

def test_uses_custom_audience(self, github_env):
session, _ = self._mock_session({"value": "the-jwt"})
context = CredentialContext(session=session, oidc_audience="my-aud")
detector = GitHubActionsDetector(context=context)

detector.get_token()

called_url = session.get.call_args[0][0]
assert "audience=my-aud" in called_url

def test_appends_audience_with_ampersand_when_query_present(self, github_env):
github_env["ACTIONS_ID_TOKEN_REQUEST_URL"] = (
"https://token.actions.example/req?foo=bar"
)
with mock.patch.dict("os.environ", github_env, clear=True):
session, _ = self._mock_session({"value": "the-jwt"})
context = CredentialContext(session=session)
detector = GitHubActionsDetector(context=context)

detector.get_token()

called_url = session.get.call_args[0][0]
assert "?foo=bar&audience=" in called_url

def test_raises_when_token_missing(self, github_env):
session, _ = self._mock_session({})
context = CredentialContext(session=session)
detector = GitHubActionsDetector(context=context)

with pytest.raises(ValueError):
detector.get_token()


class TestIntegration:
def test_detect_environment_selects_github_actions(self, github_env):
detector = detect_environment(CredentialContext())
assert isinstance(detector, GitHubActionsDetector)
Loading