LOCAL-ONLY PyPI publishing with Doppler credential management. Use when publishing to PyPI from LOCAL machine ONLY. NEVER use in CI/CD pipelines. Workspace-wide policy enforces local publishing via scripts/publish-to-pypi.sh with CI detection guards.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
scripts/publish-to-pypi.shThis skill supports LOCAL machine publishing ONLY.
❌ Publishing from GitHub Actions
❌ Publishing from any CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, CircleCI)
❌ publishCmd in semantic-release configuration
❌ Building packages in CI (uv build in prepareCmd)
❌ Storing PyPI tokens in GitHub secrets
✅ Use scripts/publish-to-pypi.sh on local machine
✅ CI detection guards in publish script
✅ Manual approval before each release
✅ Doppler credential management (no plaintext tokens)
✅ Repository verification (prevents fork abuse)
See: ADR-0027, docs/development/PUBLISHING.md
This skill provides local-only PyPI publishing using Doppler for secure credential management. It integrates with the workspace-wide release workflow where:
| Script | Purpose |
|---|---|
scripts/publish-to-pypi.sh | Local PyPI publishing with CI detection guards |
Usage: Copy to your project's scripts/ directory:
/usr/bin/env bash << 'DOPPLER_EOF'
# Environment-agnostic path
PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/cc-skills/plugins/itp}"
cp "$PLUGIN_DIR/skills/pypi-doppler/scripts/publish-to-pypi.sh" scripts/
chmod +x scripts/publish-to-pypi.sh
DOPPLER_EOF
Install Doppler CLI:
brew install dopplerhq/cli/doppler
Authenticate with Doppler:
doppler login
Verify access to claude-config project:
doppler whoami
doppler projects
Create PyPI API token:
pypi-AgEIcHlwaS5vcmc..., ~180 characters)Store token in Doppler:
doppler secrets set PYPI_TOKEN='pypi-AgEIcHlwaS5vcmc...' \
--project claude-config \
--config prd
Verify token stored:
doppler secrets get PYPI_TOKEN \
--project claude-config \
--config prd \
--plain
Pre-publish validation: Before publishing to PyPI, verify that the version has incremented from the previous release. Publishing without a version increment is invalid and wastes resources.
Autonomous check sequence:
pyproject.toml version against latest PyPI versionfeat: or fix: types."Why this matters: PyPI rejects duplicate versions, but more importantly, users and package managers rely on version increments to detect updates. A release workflow that doesn't increment version is broken.
Step 1: Development & Commit (Conventional Commits):
# Make your changes
git add .
# Commit with conventional format (determines version bump)
git commit -m "feat: add new feature" # MINOR bump
# Push to main
git push origin main
Step 2: Automated Versioning (GitHub Actions - 40-60s):
GitHub Actions workflow automatically:
@semantic-release/commit-analyzerv7.1.0)pyproject.toml, package.json versionsCHANGELOG.mdv7.1.0)[skip ci] message⚠️ PyPI publishing does NOT happen here (by design - see ADR-0027)
Step 3: Local PyPI Publishing (30 seconds):
After GitHub Actions completes, publish to PyPI locally:
# Pull the latest release commit
git pull origin main
# Publish to PyPI (uses pypi-doppler skill)
./scripts/publish-to-pypi.sh
Expected output:
🚀 Publishing to PyPI (Local Workflow)
======================================
🔐 Step 0: Verifying Doppler credentials...
✅ Doppler token verified
📥 Step 1: Pulling latest release commit...
Current version: v7.1.0
🧹 Step 2: Cleaning old builds...
✅ Cleaned
📦 Step 3: Building package...
✅ Built: dist/gapless_crypto_clickhouse-7.1.0-py3-none-any.whl
📤 Step 4: Publishing to PyPI...
Using PYPI_TOKEN from Doppler
✅ Published to PyPI
🔍 Step 5: Verifying on PyPI...
✅ Verified: https://pypi.org/project/gapless-crypto-clickhouse/7.1.0/
✅ Complete! Published v7.1.0 to PyPI in 28 seconds
CRITICAL: This command must ONLY run on your local machine, NEVER in CI/CD.
/usr/bin/env bash << 'GIT_EOF'
# First time: copy script from skill to your project (environment-agnostic)
PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/cc-skills/plugins/itp}"
cp "$PLUGIN_DIR/skills/pypi-doppler/scripts/publish-to-pypi.sh" scripts/
chmod +x scripts/publish-to-pypi.sh
# After semantic-release creates GitHub release
git pull origin main
# Publish using local copy of bundled script
./scripts/publish-to-pypi.sh
GIT_EOF
Bundled script features:
For manual publishing without the canonical script:
/usr/bin/env bash << 'CONFIG_EOF'
# Retrieve token from Doppler
PYPI_TOKEN=$(doppler secrets get PYPI_TOKEN \
--project claude-config \
--config prd \
--plain)
# Build package
uv build
# Publish to PyPI
UV_PUBLISH_TOKEN="${PYPI_TOKEN}" uv publish
CONFIG_EOF
⚠️ WARNING: Manual publishing bypasses CI detection guards and repository verification. Use canonical script unless you have a specific reason not to.
The canonical publish script (scripts/publish-to-pypi.sh) includes CI detection guards to prevent accidental execution in CI/CD pipelines.
$CI - Generic CI indicator$GITHUB_ACTIONS - GitHub Actions$GITLAB_CI - GitLab CI$JENKINS_URL - Jenkins$CIRCLECI - CircleCIIf any CI variable detected, script exits with error:
❌ ERROR: This script must ONLY be run on your LOCAL machine
Detected CI environment variables:
- CI: true
- GITHUB_ACTIONS: <not set>
...
This project enforces LOCAL-ONLY PyPI publishing for:
- Security: No long-lived PyPI tokens in GitHub secrets
- Speed: 30 seconds locally vs 3-5 minutes in CI
- Control: Manual approval step before production release
See: docs/development/PUBLISHING.md (ADR-0027)
# This should FAIL with error message
CI=true ./scripts/publish-to-pypi.sh
# Expected: ❌ ERROR: This script must ONLY be run on your LOCAL machine
Project: claude-config
Configs: prd (production), dev (development)
Secret Name: PYPI_TOKEN
Valid PyPI token format:
pypi-AgEIcHlwaS5vcmcpypi-AgEIcHlwaS5vcmcCJGI4YmNhMDA5LTg...Account-wide token (recommended):
Project-scoped token:
# 1. Create new token on PyPI
# Visit: https://pypi.org/manage/account/token/
# 2. Update Doppler
doppler secrets set PYPI_TOKEN='new-token' \
--project claude-config \
--config prd
# 3. Verify new token works
doppler secrets get PYPI_TOKEN \
--project claude-config \
--config prd \
--plain
# 4. Test publish (dry-run not available, use TestPyPI)
# See: Troubleshooting → TestPyPI Testing
Symptom: Script fails at Step 0
Fix:
# Verify token exists
doppler secrets --project claude-config --config prd | grep PYPI_TOKEN
# If missing, get new token from PyPI
# Visit: https://pypi.org/manage/account/token/
# Create token with scope: "Entire account" or specific project
# Store in Doppler
doppler secrets set PYPI_TOKEN='your-token' \
--project claude-config \
--config prd
Symptom: Script fails at Step 4 with authentication error
Root Cause: Token expired or invalid (PyPI requires 2FA since 2024)
Fix:
doppler secrets set PYPI_TOKEN='new-token' --project claude-config --config prdSymptom:
❌ ERROR: This script must ONLY be run on your LOCAL machine
Detected CI environment variables:
- CI: true
Root Cause: Running in CI environment OR CI variable set locally
Fix:
# Check if CI variable set in your shell
env | grep CI
# If set, unset it
unset CI
unset GITHUB_ACTIONS
# Retry publish
./scripts/publish-to-pypi.sh
Expected behavior: This is INTENTIONAL - script should ONLY run locally.
Symptom: Local publish uses old version number
Root Cause: Didn't pull latest release commit from GitHub
Fix:
# Always pull before publishing
git pull origin main
# Verify version updated
grep '^version = ' pyproject.toml
# Retry publish
./scripts/publish-to-pypi.sh
Symptom: Script fails at startup before any steps
Root Cause: uv not installed or not discoverable
How the script discovers uv (in priority order):
~/.local/bin/uv, ~/.cargo/bin/uv, /opt/homebrew/bin/uv)Fix: Install uv using any method:
# Official installer (recommended)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Homebrew
brew install uv
# Cargo
cargo install uv
# mise (if you use it)
mise use uv@latest
The script doesn't force any particular installation method.
Symptom: Script starts but produces no output, eventually times out
Root Cause: Script sources ~/.zshrc or ~/.bashrc which waits for interactive input
Fix: Never source shell config files in scripts. The bundled script uses:
/usr/bin/env bash << 'MISE_EOF'
# CORRECT - safe for non-interactive shells
eval "$(mise activate bash 2>/dev/null)" || true
# WRONG - hangs in non-interactive shells
source ~/.zshrc
MISE_EOF
To test publishing workflow without affecting production:
Get TestPyPI token:
Store in Doppler (separate key):
doppler secrets set TESTPYPI_TOKEN='your-test-token' \
--project claude-config \
--config prd
Modify publish script temporarily:
/usr/bin/env bash << 'DOPPLER_EOF_2'
uv publish --token "${PYPI_TOKEN}"
TESTPYPI_TOKEN=$(doppler secrets get TESTPYPI_TOKEN --plain) uv publish --repository testpypi --token "${TESTPYPI_TOKEN}"
DOPPLER_EOF_2
4. **Test publish**:
```bash
./scripts/publish-to-pypi.sh
Verify on TestPyPI:
Restore script to production configuration
docs/architecture/decisions/0027-local-only-pypi-publishing.md - Architectural decision for local-only publishingdocs/architecture/decisions/0028-skills-documentation-alignment.md - Skills alignment with ADR-0027docs/development/PUBLISHING.md - Complete release workflow guidesemantic-release - Versioning automation (NO publishing)scripts/publish-to-pypi.sh - Reference implementation with CI guardsdiscover_uv() checks PATH → direct installs → version managers (priority order)Last Updated: 2025-12-03 Policy: Workspace-wide local-only PyPI publishing (ADR-0027) Supersedes: None (created with ADR-0027 compliance from start)