Skip to content

Commit 5eb32e4

Browse files
cloudsmith-iduffyclaudeBartoszBlizniak
authored
feat: add GitHub Actions OIDC detector (#300)
Add 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. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: BB <55028730+BartoszBlizniak@users.noreply.github.com>
1 parent fc7dacc commit 5eb32e4

5 files changed

Lines changed: 189 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 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.
14+
1115
## [1.18.0] - 2026-06-09
1216

1317
### Added

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+
#### GitHub Actions OIDC Support
169+
170+
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).
171+
168172
## Configuration
169173

170174
There are two configuration files used by the CLI:

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 .github_actions import GitHubActionsDetector
1011

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

1415
logger = logging.getLogger(__name__)
1516

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""GitHub Actions OIDC detector.
3+
4+
Fetches an OIDC token via the Actions runtime HTTP endpoint, using the
5+
``ACTIONS_ID_TOKEN_REQUEST_URL`` and ``ACTIONS_ID_TOKEN_REQUEST_TOKEN``
6+
variables exposed when a workflow requests ``id-token: write`` permission.
7+
8+
References:
9+
https://docs.github.com/en/actions/reference/security/oidc
10+
https://docs.cloudsmith.com/authentication/setup-cloudsmith-to-authenticate-with-oidc-in-github-actions
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import os
16+
from urllib.parse import quote
17+
18+
from ....rest import create_requests_session as create_session
19+
from .base import EnvironmentDetector
20+
21+
DEFAULT_AUDIENCE = "cloudsmith"
22+
23+
24+
class GitHubActionsDetector(EnvironmentDetector):
25+
"""Detects GitHub Actions and fetches an OIDC token via HTTP request."""
26+
27+
name = "GitHub Actions"
28+
29+
def detect(self) -> bool:
30+
return (
31+
os.environ.get("GITHUB_ACTIONS") == "true"
32+
and bool(os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL"))
33+
and bool(os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN"))
34+
)
35+
36+
def get_token(self) -> str:
37+
request_url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]
38+
request_token = os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
39+
40+
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
41+
separator = "&" if "?" in request_url else "?"
42+
url = f"{request_url}{separator}audience={quote(audience, safe='')}"
43+
44+
session = self.context.session or create_session()
45+
try:
46+
response = session.get(
47+
url,
48+
headers={
49+
"Authorization": f"Bearer {request_token}",
50+
"Accept": "application/json; api-version=2.0",
51+
},
52+
timeout=30,
53+
)
54+
response.raise_for_status()
55+
56+
data = response.json()
57+
token = data.get("value")
58+
if not token:
59+
raise ValueError("GitHub Actions OIDC response did not contain a token")
60+
return token
61+
finally:
62+
if not self.context.session:
63+
session.close()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for the GitHub Actions 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.github_actions import (
10+
GitHubActionsDetector,
11+
)
12+
13+
14+
@pytest.fixture
15+
def github_env():
16+
env = {
17+
"GITHUB_ACTIONS": "true",
18+
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://token.actions.example/req",
19+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token",
20+
}
21+
with mock.patch.dict("os.environ", env, clear=True):
22+
yield env
23+
24+
25+
class TestDetect:
26+
def test_detects_when_all_env_vars_present(self, github_env):
27+
detector = GitHubActionsDetector(context=CredentialContext())
28+
assert detector.detect() is True
29+
30+
def test_not_detected_when_github_actions_unset(self):
31+
with mock.patch.dict("os.environ", {}, clear=True):
32+
detector = GitHubActionsDetector(context=CredentialContext())
33+
assert detector.detect() is False
34+
35+
def test_not_detected_without_request_url(self, github_env):
36+
del github_env["ACTIONS_ID_TOKEN_REQUEST_URL"]
37+
with mock.patch.dict("os.environ", github_env, clear=True):
38+
detector = GitHubActionsDetector(context=CredentialContext())
39+
assert detector.detect() is False
40+
41+
def test_not_detected_without_request_token(self, github_env):
42+
del github_env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
43+
with mock.patch.dict("os.environ", github_env, clear=True):
44+
detector = GitHubActionsDetector(context=CredentialContext())
45+
assert detector.detect() is False
46+
47+
48+
class TestGetToken:
49+
def _mock_session(self, json_data, status_ok=True):
50+
response = mock.Mock()
51+
response.json.return_value = json_data
52+
response.raise_for_status = mock.Mock()
53+
session = mock.Mock()
54+
session.get.return_value = response
55+
return session, response
56+
57+
def test_returns_token_from_response(self, github_env):
58+
session, _ = self._mock_session({"value": "the-jwt"})
59+
context = CredentialContext(session=session)
60+
detector = GitHubActionsDetector(context=context)
61+
62+
token = detector.get_token()
63+
64+
assert token == "the-jwt"
65+
66+
def test_requests_url_with_audience_and_auth_header(self, github_env):
67+
session, response = self._mock_session({"value": "the-jwt"})
68+
context = CredentialContext(session=session)
69+
detector = GitHubActionsDetector(context=context)
70+
71+
detector.get_token()
72+
73+
called_url = session.get.call_args[0][0]
74+
assert called_url.startswith("https://token.actions.example/req?audience=")
75+
assert "cloudsmith" in called_url
76+
headers = session.get.call_args[1]["headers"]
77+
assert headers["Authorization"] == "Bearer request-token"
78+
response.raise_for_status.assert_called_once()
79+
80+
def test_uses_custom_audience(self, github_env):
81+
session, _ = self._mock_session({"value": "the-jwt"})
82+
context = CredentialContext(session=session, oidc_audience="my-aud")
83+
detector = GitHubActionsDetector(context=context)
84+
85+
detector.get_token()
86+
87+
called_url = session.get.call_args[0][0]
88+
assert "audience=my-aud" in called_url
89+
90+
def test_appends_audience_with_ampersand_when_query_present(self, github_env):
91+
github_env["ACTIONS_ID_TOKEN_REQUEST_URL"] = (
92+
"https://token.actions.example/req?foo=bar"
93+
)
94+
with mock.patch.dict("os.environ", github_env, clear=True):
95+
session, _ = self._mock_session({"value": "the-jwt"})
96+
context = CredentialContext(session=session)
97+
detector = GitHubActionsDetector(context=context)
98+
99+
detector.get_token()
100+
101+
called_url = session.get.call_args[0][0]
102+
assert "?foo=bar&audience=" in called_url
103+
104+
def test_raises_when_token_missing(self, github_env):
105+
session, _ = self._mock_session({})
106+
context = CredentialContext(session=session)
107+
detector = GitHubActionsDetector(context=context)
108+
109+
with pytest.raises(ValueError):
110+
detector.get_token()
111+
112+
113+
class TestIntegration:
114+
def test_detect_environment_selects_github_actions(self, github_env):
115+
detector = detect_environment(CredentialContext())
116+
assert isinstance(detector, GitHubActionsDetector)

0 commit comments

Comments
 (0)