Publish Python Package to PyPI #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish Python Package to PyPI | |
| on: | |
| # Manual trigger only - no automatic publishing | |
| workflow_dispatch: | |
| inputs: | |
| package: | |
| description: 'Package to publish' | |
| required: true | |
| type: choice | |
| options: | |
| - sdks/python | |
| - integrations/adk-middleware/python | |
| - integrations/agent-spec/python | |
| - integrations/aws-strands/python | |
| - integrations/crew-ai/python | |
| - integrations/langgraph/python | |
| dry_run: | |
| description: 'Run in dry-run mode (build without uploading)' | |
| required: false | |
| type: boolean | |
| default: false | |
| jobs: | |
| publish: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write # Required for PyPI trusted publishing | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Authorize actor against CODEOWNERS | |
| env: | |
| ACTOR: ${{ github.actor }} | |
| PACKAGE: ${{ inputs.package }} | |
| run: | | |
| node << 'SCRIPT' | |
| const fs = require('fs'); | |
| const actor = process.env.ACTOR; | |
| const pkg = process.env.PACKAGE; | |
| // Parse CODEOWNERS | |
| const lines = fs.readFileSync('.github/CODEOWNERS', 'utf-8').split('\n'); | |
| const rules = []; | |
| let rootOwners = []; | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith('#')) continue; | |
| const parts = trimmed.split(/\s+/); | |
| const pattern = parts[0]; | |
| const owners = parts.slice(1).map(o => o.replace('@', '')); | |
| if (pattern === '*') { | |
| rootOwners = owners; | |
| } else { | |
| rules.push({ pattern, owners }); | |
| } | |
| } | |
| // Find the most specific matching rule for this package path | |
| // Strip trailing /python to match CODEOWNERS entries like "integrations/adk-middleware" | |
| const pathsToCheck = [pkg]; | |
| const pythonSuffix = '/python'; | |
| if (pkg.endsWith(pythonSuffix)) { | |
| pathsToCheck.push(pkg.slice(0, -pythonSuffix.length)); | |
| } | |
| let authorized = false; | |
| let matchedRule = null; | |
| for (const checkPath of pathsToCheck) { | |
| for (const rule of rules) { | |
| const pattern = rule.pattern.replace(/\/$/, ''); | |
| if (checkPath === pattern || checkPath.startsWith(pattern + '/')) { | |
| matchedRule = rule; | |
| authorized = rule.owners.includes(actor); | |
| break; | |
| } | |
| } | |
| if (matchedRule) break; | |
| } | |
| // Fall back to root owners if no specific rule matched | |
| if (!matchedRule) { | |
| matchedRule = { pattern: '*', owners: rootOwners }; | |
| authorized = rootOwners.includes(actor); | |
| } | |
| console.log(`Actor: ${actor}`); | |
| console.log(`Package: ${pkg}`); | |
| console.log(`Matched rule: ${matchedRule.pattern} -> ${matchedRule.owners.join(', ')}`); | |
| console.log(`Authorized: ${authorized}`); | |
| if (!authorized) { | |
| console.error(`\nERROR: ${actor} is not a CODEOWNERS owner for ${pkg}`); | |
| console.error(`Allowed users: ${matchedRule.owners.join(', ')}`); | |
| process.exit(1); | |
| } | |
| SCRIPT | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v4 | |
| with: | |
| version: ">=0.8.0" | |
| - name: Set up Python | |
| run: uv python install 3.12 | |
| - name: Install dependencies | |
| working-directory: ${{ inputs.package }} | |
| run: uv sync | |
| - name: Run tests | |
| working-directory: ${{ inputs.package }} | |
| run: uv run python -m pytest | |
| - name: Build package | |
| working-directory: ${{ inputs.package }} | |
| run: uv build | |
| - name: Verify wheel permissions | |
| working-directory: ${{ inputs.package }} | |
| run: | | |
| echo "## Wheel file permissions check" | |
| uv run python -c " | |
| import zipfile, glob, sys | |
| whl = glob.glob('dist/*.whl')[0] | |
| print(f'Checking {whl}') | |
| bad = [] | |
| for info in zipfile.ZipFile(whl).infolist(): | |
| perms = (info.external_attr >> 16) & 0o777 | |
| readable = perms & 0o444 | |
| print(f' {oct(perms):>8s} {info.filename}') | |
| if not readable: | |
| bad.append(info.filename) | |
| if bad: | |
| print(f'ERROR: {len(bad)} file(s) missing read permissions:') | |
| for f in bad: | |
| print(f' - {f}') | |
| sys.exit(1) | |
| print('All files have correct permissions.') | |
| " | |
| - name: Publish to PyPI | |
| if: inputs.dry_run == false | |
| working-directory: ${{ inputs.package }} | |
| run: uv publish | |
| env: | |
| UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} | |
| - name: Summary | |
| if: success() | |
| working-directory: ${{ inputs.package }} | |
| run: | | |
| NAME=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['name'])") | |
| VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| echo "## ✅ Dry-run Complete!" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Built **${NAME} ${VERSION}** successfully. No artifacts were uploaded." >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "## ✅ Published ${NAME} ${VERSION} to PyPI" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Install with: \`pip install ${NAME}==${VERSION}\`" >> $GITHUB_STEP_SUMMARY | |
| fi |