From a8a533bd131108c2838013d17ff667250f6372d6 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 8 Jun 2026 23:37:33 +0100 Subject: [PATCH] feat: add GitHub Actions OIDC detector 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) --- CHANGELOG.md | 4 + README.md | 4 + .../credentials/oidc/detectors/__init__.py | 2 + .../oidc/detectors/github_actions.py | 63 ++++++++++ .../tests/test_github_actions_detector.py | 116 ++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py create mode 100644 cloudsmith_cli/core/tests/test_github_actions_detector.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8009ae30..ecb3cffd 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 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.17.0] - 2026-05-18 ### Added diff --git a/README.md b/README.md index c586b108..466b029b 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. +#### 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: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index 9b88077c..ae7ee7e4 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 .github_actions import GitHubActionsDetector if TYPE_CHECKING: from ... import CredentialContext @@ -14,6 +15,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + GitHubActionsDetector, AWSDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py b/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py new file mode 100644 index 00000000..16febff6 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py @@ -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() diff --git a/cloudsmith_cli/core/tests/test_github_actions_detector.py b/cloudsmith_cli/core/tests/test_github_actions_detector.py new file mode 100644 index 00000000..a34b1e5a --- /dev/null +++ b/cloudsmith_cli/core/tests/test_github_actions_detector.py @@ -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)