From 364490e815772e26d4a874e782a781b4382f7f14 Mon Sep 17 00:00:00 2001 From: Garot Conklin Date: Tue, 28 Jan 2025 16:30:14 -0500 Subject: [PATCH 1/2] feature/#1 - Initial project setup with CI/CD, documentation, and build scripts --- .flake8 | 14 +++ .github/dependabot.yml | 42 +++++++ .github/workflows/sonarcloud.yml | 51 ++++++++ .github/workflows/workflow.yml | 57 +++++++++ README.md | 14 +++ githubauthlib/__init__.py | 15 +-- githubauthlib/github_auth.py | 210 ++++++++++++------------------- scripts/build_and_publish.sh | 92 ++++++++++++++ scripts/test_and_lint.sh | 64 ++++++++++ tests/test_github_auth.py | 167 ++++++++++++++++-------- 10 files changed, 536 insertions(+), 190 deletions(-) create mode 100644 .flake8 create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/sonarcloud.yml create mode 100644 .github/workflows/workflow.yml create mode 100755 scripts/build_and_publish.sh create mode 100755 scripts/test_and_lint.sh diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e635a3e --- /dev/null +++ b/.flake8 @@ -0,0 +1,14 @@ +[flake8] +max-line-length = 100 +exclude = + .git, + __pycache__, + build, + dist, + *.egg-info, + .venv, + .tox, + .pytest_cache +statistics = True +count = True +show-source = True \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0214096 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + target-branch: "main" + labels: + - "security" + - "dependencies" + commit-message: + prefix: "security" + include: "scope" + reviewers: + - "garotm" + assignees: + - "garotm" + versioning-strategy: + increase-if-necessary: true + allow: + - dependency-type: "direct" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] + security-updates-only: true + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 3 + labels: + - "security" + - "github-actions" + commit-message: + prefix: "security" + include: "scope" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] + security-updates-only: true \ No newline at end of file diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000..58af962 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,51 @@ +name: SonarCloud Analysis +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests with coverage + run: | + pytest tests/ --cov=githubauthlib --cov-report=xml --cov-report=term-missing + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=flexrpl + -Dsonar.projectKey=fleXRPL_githubauthlib + -Dsonar.python.coverage.reportPaths=coverage.xml + -Dsonar.sources=githubauthlib + -Dsonar.tests=tests + -Dsonar.python.version=3 + -Dsonar.sourceEncoding=UTF-8 + -Dsonar.exclusions=docs/**,scripts/** + -Dsonar.coverage.exclusions=tests/**,docs/**,scripts/** + -Dsonar.python.xunit.reportPath=test-results.xml \ No newline at end of file diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..c018100 --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,57 @@ +name: workflow + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install black isort flake8 pytest pytest-cov + - name: Run tests and linting + run: | + black --check githubauthlib tests + isort --check githubauthlib tests + flake8 githubauthlib tests + pytest tests/ --cov=githubauthlib --cov-report=xml --cov-fail-under=90 + + publish: + needs: test + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/README.md b/README.md index 357cc1a..867f180 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ # GitHub Authentication Library (githubauthlib) +[![PyPI version](https://badge.fury.io/py/githubauthlib.svg)](https://pypi.org/project/githubauthlib/) +[![Python](https://img.shields.io/pypi/pyversions/githubauthlib.svg)](https://pypi.org/project/githubauthlib/) +[![Tests](https://github.com/fleXRPL/githubauthlib/actions/workflows/tests.yml/badge.svg)](https://github.com/fleXRPL/githubauthlib/actions/workflows/tests.yml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=fleXRPL_githubauthlib&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=fleXRPL_githubauthlib) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=fleXRPL_githubauthlib&metric=coverage)](https://sonarcloud.io/summary/new_code?id=fleXRPL_githubauthlib) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=fleXRPL_githubauthlib&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=fleXRPL_githubauthlib) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=fleXRPL_githubauthlib&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=fleXRPL_githubauthlib) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=fleXRPL_githubauthlib&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=fleXRPL_githubauthlib) +[![Dependabot Status](https://img.shields.io/badge/Dependabot-enabled-success.svg)](https://github.com/fleXRPL/githubauthlib/blob/main/.github/dependabot.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) +[![Downloads](https://pepy.tech/badge/githubauthlib)](https://pepy.tech/project/githubauthlib) + A Python library for securely retrieving GitHub tokens from system keychains across different operating systems. ## Features diff --git a/githubauthlib/__init__.py b/githubauthlib/__init__.py index 80d0809..024606c 100644 --- a/githubauthlib/__init__.py +++ b/githubauthlib/__init__.py @@ -5,13 +5,10 @@ from various system-specific secure storage solutions. """ -from .github_auth import ( - get_github_token, - GitHubAuthError, - CredentialHelperError, - UnsupportedPlatformError -) +from .github_auth import get_github_token -__version__ = '1.0.0' -__author__ = 'garotm' -__license__ = 'MIT' +__version__ = "1.0.0" +__author__ = "garotm" +__license__ = "MIT" + +__all__ = ["get_github_token"] diff --git a/githubauthlib/github_auth.py b/githubauthlib/github_auth.py index d758e25..c187e1a 100644 --- a/githubauthlib/github_auth.py +++ b/githubauthlib/github_auth.py @@ -1,144 +1,96 @@ #!/usr/bin/env python3 """ -This module provides GitHub authentication for macOS, Windows, and Linux systems. +This module provides GitHub auth for macOS or Windows. -The module retrieves GitHub tokens from the system's secure storage: -- macOS: Keychain Access -- Windows: Credential Manager -- Linux: libsecret - -Written by: garotm +Written by: Garot Conklin """ -import subprocess import platform -import logging -import re -from typing import Optional - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -class GitHubAuthError(Exception): - """Base exception for GitHub authentication errors.""" - pass - -class CredentialHelperError(GitHubAuthError): - """Raised when there's an error with the credential helper.""" - pass +import subprocess -class UnsupportedPlatformError(GitHubAuthError): - """Raised when the operating system is not supported.""" - pass -def validate_token(token: str) -> bool: +def get_github_token(): """ - Validate the format of a GitHub token. - - Args: - token (str): The token to validate - - Returns: - bool: True if the token format is valid, False otherwise - """ - # GitHub tokens are 40 characters long and contain only hexadecimal characters - token_pattern = re.compile(r'^gh[ps]_[A-Za-z0-9_]{36}$|^[a-f0-9]{40}$') - return bool(token_pattern.match(token)) + Retrieves the GitHub token from the system's keychain. -def get_github_token() -> Optional[str]: - """ - Retrieves the GitHub token from the system's secure storage. + This function uses the 'git' command-line utility to interact with the + system's keychain. If the system is MacOS, it uses the 'osxkeychain' + credential helper. If the system is Windows, it uses the 'wincred' + credential helper. For Linux, it uses libsecret or git credential store. + For other systems, it prints an error message. Returns: - str: The GitHub token if found and valid - - Raises: - CredentialHelperError: If there's an error accessing the credential helper - UnsupportedPlatformError: If the operating system is not supported + str: The GitHub token if it could be found, or None otherwise. """ - system = platform.system() - - try: - if system == "Darwin": - return _get_token_macos() - elif system == "Windows": - return _get_token_windows() - elif system == "Linux": - return _get_token_linux() - else: - raise UnsupportedPlatformError(f"Unsupported operating system: {system}") - except subprocess.CalledProcessError as e: - raise CredentialHelperError(f"Error accessing credential helper: {str(e)}") + if platform.system() == "Darwin": + try: + output = subprocess.check_output( + ["git", "credential-osxkeychain", "get"], + input="protocol=https\nhost=github.com\n", + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + access_token = output.strip().split()[0].split("=")[1] + return access_token + except subprocess.CalledProcessError: + print("GitHub access token not found in osxkeychain.") + return None + elif platform.system() == "Windows": + try: + output = subprocess.check_output( + ["git", "config", "--get", "credential.helper"], + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + if output.strip() == "manager": + output = subprocess.check_output( + ["git", "credential", "fill"], + input="url=https://github.com", + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + credentials = {} + for line in output.strip().split("\n"): + key, value = line.split("=") + credentials[key] = value.strip() + access_token = credentials.get("password") + return access_token + print("GitHub access token not found in Windows Credential Manager.") + return None + except subprocess.CalledProcessError: + print("Error retrieving GitHub credential helper.") + return None + elif platform.system() == "Linux": + try: + # Try using libsecret (GNOME Keyring) + output = subprocess.check_output( + ["secret-tool", "lookup", "host", "github.com"], + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + if output.strip(): + return output.strip() + except FileNotFoundError: + print("secret-tool not found, falling back to git credential store.") + except subprocess.CalledProcessError: + print("No token found in libsecret, falling back to git credential store.") -def _get_token_macos() -> Optional[str]: - """Retrieve token from macOS keychain.""" - output = subprocess.check_output( - ["git", "credential-osxkeychain", "get"], - input="protocol=https\nhost=github.com\n", - universal_newlines=True, - stderr=subprocess.PIPE - ) - - for line in output.split('\n'): - if line.startswith('password='): - token = line.split('=')[1].strip() - if validate_token(token): - return token - else: - logger.warning("Retrieved token failed validation") - return None - - logger.info("No GitHub token found in macOS keychain") - return None - -def _get_token_windows() -> Optional[str]: - """Retrieve token from Windows Credential Manager.""" - helper = subprocess.check_output( - ["git", "config", "--get", "credential.helper"], - universal_newlines=True - ).strip() - - if helper not in ["manager", "manager-core", "wincred"]: - raise CredentialHelperError("Windows credential manager not configured") - - output = subprocess.check_output( - ["git", "credential", "fill"], - input="url=https://github.com\n\n", - universal_newlines=True - ) - - credentials = dict( - line.split('=', 1) - for line in output.strip().split('\n') - if '=' in line - ) - - token = credentials.get('password') - if token and validate_token(token): - return token - - logger.info("No valid GitHub token found in Windows Credential Manager") - return None - -def _get_token_linux() -> Optional[str]: - """Retrieve token from Linux libsecret.""" - try: - output = subprocess.check_output( - ["secret-tool", "lookup", "host", "github.com"], - universal_newlines=True - ) - - token = output.strip() - if validate_token(token): - return token - - logger.warning("Retrieved token failed validation") - return None - - except FileNotFoundError: - logger.error("libsecret-tools not installed. Please install using your package manager.") + # Fall back to git credential store + try: + output = subprocess.check_output( + ["git", "credential", "fill"], + input="url=https://github.com\n\n", + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + for line in output.strip().split("\n"): + if line.startswith("password="): + return line.split("=", 1)[1].strip() + print("GitHub access token not found in git credential store.") + return None + except subprocess.CalledProcessError: + print("Error retrieving GitHub credential from git store.") + return None + else: + print("Unsupported operating system.") return None diff --git a/scripts/build_and_publish.sh b/scripts/build_and_publish.sh new file mode 100755 index 0000000..418dabd --- /dev/null +++ b/scripts/build_and_publish.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Exit on error +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting build preparation...${NC}\n" + +# Function to run checks and report status +run_check() { + local check_name=$1 + local command=$2 + + echo -e "${YELLOW}Running ${check_name}...${NC}" + if eval "$command"; then + echo -e "${GREEN}✓ ${check_name} passed${NC}\n" + return 0 + else + echo -e "${RED}✗ ${check_name} failed${NC}\n" + return 1 + fi +} + +# Function to clean up +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + if [ -n "${VIRTUAL_ENV}" ]; then + deactivate 2>/dev/null || true + fi + rm -rf .venv/ + echo -e "${GREEN}✓ Cleanup completed${NC}\n" +} + +# Create and activate virtual environment +echo -e "${YELLOW}Creating virtual environment...${NC}" +python3 -m venv .venv +source .venv/bin/activate +echo -e "${GREEN}✓ Virtual environment created and activated${NC}\n" + +# Clean previous builds +echo -e "${YELLOW}Cleaning previous builds...${NC}" +rm -rf build/ dist/ *.egg-info +echo -e "${GREEN}✓ Previous builds cleaned${NC}\n" + +# Install build dependencies +echo -e "${YELLOW}Installing dependencies...${NC}" +python -m pip install --upgrade pip +pip install -r requirements.txt +pip install black isort flake8 pytest pytest-cov build +echo -e "${GREEN}✓ Dependencies installed${NC}\n" + +# Run tests and checks +echo -e "${YELLOW}Running tests and checks...${NC}" + +# Format and lint +run_check "Black formatting" "black githubauthlib tests" +run_check "isort check" "isort githubauthlib tests" +run_check "Flake8 linting" "flake8 githubauthlib tests" + +# Run tests with coverage +run_check "Pytest with coverage" "pytest tests/ --cov=githubauthlib --cov-report=term-missing --cov-fail-under=90" + +echo -e "${GREEN}✓ All checks passed${NC}\n" + +# Build package +echo -e "${YELLOW}Building package...${NC}" +if python -m build; then + echo -e "${GREEN}✓ Package built${NC}\n" + + # List generated files + echo -e "\n${YELLOW}Generated files:${NC}" + ls -l dist/ + + echo -e "\n${YELLOW}Next steps:${NC}" + echo -e "1. Create and push a new version tag: ${GREEN}git tag v1.0.0 && git push origin v1.0.0${NC}" + echo -e "2. The GitHub Action will automatically publish to PyPI" + echo -e "3. Once published, verify the package: ${GREEN}https://pypi.org/project/githubauthlib/${NC}" + echo -e "4. Test installation: ${GREEN}pip install githubauthlib${NC}" + + # Clean up only if build was successful + cleanup + exit 0 +else + echo -e "${RED}✗ Package build failed${NC}\n" + cleanup + exit 1 +fi \ No newline at end of file diff --git a/scripts/test_and_lint.sh b/scripts/test_and_lint.sh new file mode 100755 index 0000000..e4d7c6e --- /dev/null +++ b/scripts/test_and_lint.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Exit on error +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting test and lint checks...${NC}\n" + +# Function to run a command and check its status +run_check() { + echo -e "${YELLOW}Running $1...${NC}" + if eval "$2"; then + echo -e "${GREEN}✓ $1 passed${NC}\n" + else + echo -e "${RED}✗ $1 failed${NC}\n" + exit 1 + fi +} + +# Function to clean up +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + deactivate 2>/dev/null || true + rm -rf .venv/ + echo -e "${GREEN}✓ Cleanup completed${NC}\n" +} + +# Set up trap to clean up on script exit +trap cleanup EXIT + +# Create and activate virtual environment +echo -e "${YELLOW}Creating virtual environment...${NC}" +python3 -m venv .venv +source .venv/bin/activate +echo -e "${GREEN}✓ Virtual environment created and activated${NC}\n" + +# Install dependencies +echo -e "${YELLOW}Installing dependencies...${NC}" +python -m pip install --upgrade pip +pip install -r requirements.txt +pip install black isort flake8 pytest pytest-cov +echo -e "${GREEN}✓ Dependencies installed${NC}\n" + +# Format code +run_check "Black formatting" "black githubauthlib tests" +run_check "isort check" "isort githubauthlib tests" + +# Lint with flake8 +run_check "Flake8 linting" "flake8 githubauthlib tests" + +# Run tests with coverage +run_check "Pytest with coverage" "pytest tests/ --cov=githubauthlib --cov-report=term-missing --cov-fail-under=90" + +# Generate coverage report +echo -e "${YELLOW}Generating HTML coverage report...${NC}" +coverage html +echo -e "${GREEN}Coverage report generated in htmlcov/index.html${NC}\n" + +echo -e "${GREEN}All checks passed successfully!${NC}" \ No newline at end of file diff --git a/tests/test_github_auth.py b/tests/test_github_auth.py index 5cc062e..4374a80 100644 --- a/tests/test_github_auth.py +++ b/tests/test_github_auth.py @@ -1,77 +1,140 @@ # tests/test_github_auth.py -import unittest -from unittest.mock import patch, MagicMock import platform import subprocess -from githubauthlib import ( - get_github_token, - GitHubAuthError, - CredentialHelperError, - UnsupportedPlatformError -) +import unittest +from unittest.mock import patch + +from githubauthlib import get_github_token + class TestGitHubAuth(unittest.TestCase): """Test cases for GitHub authentication functionality.""" def setUp(self): """Set up test cases.""" - self.valid_token = "ghp_1234567890abcdef1234567890abcdef123456" - self.invalid_token = "invalid_token" + self.test_token = "ghp_1234567890abcdef1234567890abcdef123456" + self.current_platform = platform.system() # Store current platform for tests - @patch('platform.system') - @patch('subprocess.check_output') - def test_macos_valid_token(self, mock_subprocess, mock_platform): + @patch("platform.system") + @patch("subprocess.check_output") + def test_macos_token_retrieval(self, mock_subprocess, mock_platform): """Test successful token retrieval on macOS.""" mock_platform.return_value = "Darwin" - mock_subprocess.return_value = f"password={self.valid_token}\n" - + mock_subprocess.return_value = f"password={self.test_token}\n" + token = get_github_token() - self.assertEqual(token, self.valid_token) + self.assertEqual(token, self.test_token) + mock_subprocess.assert_called_with( + ["git", "credential-osxkeychain", "get"], + input="protocol=https\nhost=github.com\n", + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) - @patch('platform.system') - @patch('subprocess.check_output') - def test_windows_valid_token(self, mock_subprocess, mock_platform): + @patch("platform.system") + @patch("subprocess.check_output") + def test_macos_no_token(self, mock_subprocess, mock_platform): + """Test macOS behavior when no token is found.""" + mock_platform.return_value = "Darwin" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "git") + + token = get_github_token() + self.assertIsNone(token) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_windows_token_retrieval(self, mock_subprocess, mock_platform): """Test successful token retrieval on Windows.""" mock_platform.return_value = "Windows" mock_subprocess.side_effect = [ "manager\n", - f"password={self.valid_token}\n" + f"protocol=https\nhost=github.com\npassword={self.test_token}\n", ] - + token = get_github_token() - self.assertEqual(token, self.valid_token) + self.assertEqual(token, self.test_token) - @patch('platform.system') + @patch("platform.system") + @patch("subprocess.check_output") + def test_windows_no_manager(self, mock_subprocess, mock_platform): + """Test Windows behavior when credential manager is not configured.""" + mock_platform.return_value = "Windows" + mock_subprocess.return_value = "wincred\n" + + token = get_github_token() + self.assertIsNone(token) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_windows_credential_error(self, mock_subprocess, mock_platform): + """Test Windows behavior when credential helper fails.""" + mock_platform.return_value = "Windows" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "git") + + token = get_github_token() + self.assertIsNone(token) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_linux_libsecret_token(self, mock_subprocess, mock_platform): + """Test successful token retrieval on Linux using libsecret.""" + mock_platform.return_value = "Linux" + mock_subprocess.return_value = self.test_token + + token = get_github_token() + self.assertEqual(token, self.test_token) + mock_subprocess.assert_called_with( + ["secret-tool", "lookup", "host", "github.com"], + universal_newlines=True, + stderr=subprocess.DEVNULL, + ) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_linux_fallback_token(self, mock_subprocess, mock_platform): + """Test Linux fallback to git credential store.""" + mock_platform.return_value = "Linux" + mock_subprocess.side_effect = [ + FileNotFoundError(), # secret-tool not found + f"protocol=https\nhost=github.com\npassword={self.test_token}\n", + ] + + token = get_github_token() + self.assertEqual(token, self.test_token) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_linux_all_methods_fail(self, mock_subprocess, mock_platform): + """Test Linux behavior when both libsecret and git credential store fail.""" + mock_platform.return_value = "Linux" + mock_subprocess.side_effect = [ + subprocess.CalledProcessError(1, "secret-tool"), # libsecret fails + subprocess.CalledProcessError(1, "git"), # git credential store fails + ] + + token = get_github_token() + self.assertIsNone(token) + + @patch("platform.system") + @patch("subprocess.check_output") + def test_linux_credential_store_no_password(self, mock_subprocess, mock_platform): + """Test Linux git credential store with no password in output.""" + mock_platform.return_value = "Linux" + mock_subprocess.side_effect = [ + FileNotFoundError(), # secret-tool not found + "protocol=https\nhost=github.com\n", # no password in output + ] + + token = get_github_token() + self.assertIsNone(token) + + @patch("platform.system") def test_unsupported_platform(self, mock_platform): """Test behavior with unsupported platform.""" mock_platform.return_value = "SomeOS" - - with self.assertRaises(UnsupportedPlatformError): - get_github_token() - - @patch('platform.system') - @patch('subprocess.check_output') - def test_credential_helper_error(self, mock_subprocess, mock_platform): - """Test handling of credential helper errors.""" - mock_platform.return_value = "Darwin" - mock_subprocess.side_effect = subprocess.CalledProcessError(1, "git") - - with self.assertRaises(CredentialHelperError): - get_github_token() - - def test_token_validation(self): - """Test token validation functionality.""" - from githubauthlib.github_auth import validate_token - - # Test valid tokens - self.assertTrue(validate_token("ghp_1234567890abcdef1234567890abcdef123456")) - self.assertTrue(validate_token("ghs_1234567890abcdef1234567890abcdef123456")) - self.assertTrue(validate_token("0123456789abcdef0123456789abcdef01234567")) - - # Test invalid tokens - self.assertFalse(validate_token("invalid_token")) - self.assertFalse(validate_token("ghp_tooshort")) - self.assertFalse(validate_token("ghi_1234567890abcdef1234567890abcdef123456")) - -if __name__ == '__main__': + token = get_github_token() + self.assertIsNone(token) + + +if __name__ == "__main__": unittest.main() From b307af1d993d74381f20a722b775df0a850e53b9 Mon Sep 17 00:00:00 2001 From: Garot Conklin Date: Tue, 28 Jan 2025 16:42:22 -0500 Subject: [PATCH 2/2] feature/#1 - fix PYPI.md to reflect the Trusted Publisher approach --- PYPI.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/PYPI.md b/PYPI.md index 0219695..e3f9306 100644 --- a/PYPI.md +++ b/PYPI.md @@ -47,7 +47,7 @@ You'll need `twine` to securely upload your package to PyPI. If you don't have i ```bash pip install twine - `````` + ``` 5. Upload the package to PyPI: Use `twine` to upload your package to PyPI. Run the following command: @@ -98,3 +98,87 @@ Now that your package is on PyPI, you can install it using `pip` like any other That's it! Your package is now available on PyPI and can be easily installed by others using `pip install githubauthlib`. Keep in mind that publishing packages on PyPI is a public act, and it's essential to ensure your code is properly documented, well-tested, and adheres to best practices. Make sure to thoroughly test your package and keep it up-to-date with new releases if necessary. + +# Publishing to PyPI using GitHub Actions Trusted Publisher + +This project uses GitHub Actions and PyPI's trusted publisher workflow for secure, automated package publishing. + +## Overview + +Instead of manual uploads or stored credentials, we use GitHub's OIDC (OpenID Connect) integration with PyPI for secure publishing. This means: + +- No API tokens or credentials needed +- Automated publishing on version tags +- Secure authentication via OIDC + +## Publishing Process + +1. **Local Build and Test** + + ```bash + # Run the build script to verify everything locally + ./scripts/build_and_publish.sh + ``` + + This will: + - Create a virtual environment + - Run all tests and checks + - Build the package locally + - Clean up afterward + +2. **Create and Push a Version Tag** + + ```bash + # Create and push a new version tag + git tag v1.0.0 + git push origin v1.0.0 + ``` + + The version number should match what's in `setup.py`. + +3. **Automated Publishing** + - GitHub Actions will trigger on the tag push + - The workflow will: + - Run all tests + - Build the package + - Publish to PyPI using OIDC authentication + - Monitor the Actions tab for progress + +4. **Verify Publication** + - Check the package page: https://pypi.org/project/githubauthlib/ + - Try installing the package: + + ```bash + pip install githubauthlib + ``` + +## PyPI Project Configuration + +The PyPI project is configured with the following trusted publisher settings: + +- **Publisher**: GitHub Actions +- **Organization**: fleXRPL +- **Repository**: githubauthlib +- **Workflow name**: workflow.yml +- **Environment**: pypi + +## Security Notes + +- No credentials are stored in the repository or GitHub secrets +- Authentication is handled via OIDC between GitHub and PyPI +- Only tagged commits from the main branch can trigger publishing +- All publishing attempts are logged and auditable + +## Troubleshooting + +If publishing fails: + +1. Check the GitHub Actions logs +2. Verify the version tag matches setup.py +3. Ensure the workflow file matches PyPI's trusted publisher configuration +4. Verify the package builds locally with `./scripts/build_and_publish.sh` + +## Related Links + +- [PyPI Trusted Publishers Documentation](https://docs.pypi.org/trusted-publishers/) +- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)