Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
365 changes: 365 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
name: Publish to PyPI

on:
release:
types: [created]
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.14.1)'
required: true
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9\-\.]+)?$'

permissions:
contents: read

jobs:
version-check:
name: Check Version and Generate Summary
runs-on: ubuntu-latest
outputs:
should-deploy: ${{ steps.check-version.outputs.should-deploy }}
current-version: ${{ steps.check-version.outputs.current-version }}
target-version: ${{ steps.check-version.outputs.target-version }}
change-summary: ${{ steps.generate-summary.outputs.summary }}

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog generation

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests==2.31.0 packaging==23.1

- name: Determine target version
id: get-version
run: |
import re
import sys
import os

# Version validation regex
version_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9\-\.]+)?$'

if "${{ github.event_name }}" == "release":
version = "${{ github.event.release.tag_name }}"
# Remove 'v' prefix if present
if version.startswith('v'):
version = version[1:]
else:
version = "${{ inputs.version }}"

# Validate version format
if not re.match(version_pattern, version):
print(f"Invalid version format: {version}")
print("Version must match pattern: X.Y.Z or X.Y.Z-suffix")
sys.exit(1)

# Additional length check for safety
if len(version) > 50:
print(f"Version string too long: {len(version)} characters")
sys.exit(1)

print(f"Valid version format: {version}")

# Set output safely
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"target-version={version}\n")
shell: python

- name: Check version against PyPI
id: check-version
run: |
import os
import requests
import sys
import tomllib
from packaging import version

target_ver = "${{ steps.get-version.outputs.target-version }}"
package_name = "secops"
current_ver = "0.0.0"
should_deploy = "false"

print(f"Checking version {target_ver} against PyPI...")

try:
# Get current PyPI version
response = requests.get(f"https://pypi.org/pypi/{package_name}/json",
timeout=10)

if response.status_code == 404:
# Package not on PyPI, this is a new package
print(f"Package {package_name} not found on PyPI.")
current_ver = "0.0.0" # Use baseline for new packages
print(f"Using baseline version for new package: {current_ver}")
print("This appears to be the first PyPI release.")
elif response.status_code == 200:
data = response.json()
current_ver = data["info"]["version"]
print(f"Current PyPI version: {current_ver}")
else:
print(f"Error fetching PyPI data: {response.status_code}")
sys.exit(1)

# Compare versions
if version.parse(target_ver) > version.parse(current_ver):
print(f"Version {target_ver} is newer than {current_ver}")
should_deploy = "true"
else:
print(f"Version {target_ver} is not newer than {current_ver}")
should_deploy = "false"

# Set outputs
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"should-deploy={should_deploy}\n")
f.write(f"current-version={current_ver}\n")
f.write(f"target-version={target_ver}\n")

if should_deploy == "false":
print("Stopping workflow: Version is not newer than PyPI.")
sys.exit(1)

except Exception as e:
print(f"Error during version check: {e}")
# Ensure outputs are set even on error
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"should-deploy=false\n")
f.write(f"current-version={current_ver}\n")
f.write(f"target-version={target_ver}\n")
sys.exit(1)
shell: python

- name: Generate change summary
id: generate-summary
run: |
import subprocess
import os
import re
import sys

target_version = "${{ steps.check-version.outputs.target-version }}"
current_version = "${{ steps.check-version.outputs.current-version }}"

# Validate inputs (extra safety)
version_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9\-\.]+)?$'
if not re.match(version_pattern, target_version):
print(f"Invalid target version format: {target_version}")
sys.exit(1)
if not re.match(version_pattern, current_version):
print(f"Invalid current version format: {current_version}")
sys.exit(1)

print("Generating change summary...")

try:
# Get git tags safely
result = subprocess.run(
['git', 'tag', '--sort=-version:refname'],
capture_output=True, text=True, timeout=30
)

if result.returncode == 0:
tags = result.stdout.strip().split('\n')
# Filter out current version tag
prev_tags = [tag for tag in tags
if tag and not tag.endswith(target_version)
and not tag == f"v{target_version}"]
prev_tag = prev_tags[0] if prev_tags else None
else:
prev_tag = None

# Get changes safely
if prev_tag:
print(f"Comparing changes since {prev_tag}")
cmd = ['git', 'log', '--oneline', '--no-merges',
f'{prev_tag}..HEAD']
else:
print("No previous tag found, showing recent commits")
cmd = ['git', 'log', '--oneline', '--no-merges', '-20']

result = subprocess.run(cmd, capture_output=True, text=True,
timeout=30)
changes = result.stdout.strip() if result.returncode == 0 else "No changes found"

# Limit changes length for security
if len(changes) > 2000:
changes = changes[:2000] + "\n... (truncated)"

except Exception as e:
print(f"Error getting git changes: {e}")
changes = "Unable to retrieve changes"

# Create summary with safe formatting
summary = f"""## Publishing secops v{target_version}

**Current PyPI version:** {current_version}
**Target version:** {target_version}

### Recent Changes
```
{changes}
```

### Package Info
- **Package:** secops
- **Repository:** ${{ github.repository }}
- **Triggered by:** ${{ github.actor }}
"""

# Write to output using the proper multi-line syntax
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
f.write(summary)

print("Change summary written to GITHUB_STEP_SUMMARY")

# Write summary to GITHUB_OUTPUT for downstream jobs to use
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
# Use simplified format to avoid delimiter issues
simple_summary = f"Publishing from {current_version} to {target_version}"
f.write(f"summary={simple_summary}\n")

shell: python

approval:
name: Wait for Maintainer Approval
needs: version-check
if: needs.version-check.outputs.should-deploy == 'true'
runs-on: ubuntu-latest
environment:
name: pypi-publish

steps:
- name: Display deployment summary
run: |
echo "**Deployment Summary**"
echo ""
echo "${{ needs.version-check.outputs.change-summary }}"
echo ""
echo "**Action Required:** A maintainer must approve this deployment"
echo " to proceed with publishing to PyPI."
env:
SUMMARY: ${{ needs.version-check.outputs.change-summary }}

- name: Approval checkpoint
run: |
echo "Deployment approved by maintainer"
echo "Proceeding with PyPI publication..."

publish:
name: Build and Publish to PyPI
needs: [version-check, approval]
if: needs.version-check.outputs.should-deploy == 'true'
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Verify version in pyproject.toml
run: |
import tomllib
import re
import sys

target_version = "${{ needs.version-check.outputs.target-version }}"

# Validate target version format
version_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9\-\.]+)?$'
if not re.match(version_pattern, target_version):
print(f"Invalid target version format: {target_version}")
sys.exit(1)

try:
with open('pyproject.toml', 'rb') as f:
pyproject_data = tomllib.load(f)
pyproject_version = pyproject_data['project']['version']
except Exception as e:
print(f"Error reading pyproject.toml: {e}")
sys.exit(1)

print(f"pyproject.toml version: {pyproject_version}")
print(f"Target version: {target_version}")

if pyproject_version != target_version:
print("Version mismatch!")
print(f"Please update pyproject.toml version to {target_version}")
sys.exit(1)

print("Version matches target")
shell: python

- name: Build package
run: |
echo "Building package..."
python -m build

echo "Built packages:"
ls -la dist/

- name: Check package
run: |
echo "Checking package integrity..."
python -m twine check dist/*

- name: Publish to PyPI
run: |
python -m twine upload dist/* --verbose
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
TWINE_NON_INTERACTIVE: 1

- name: Create deployment summary
run: |
import os
import re
from datetime import datetime

target_version = "${{ needs.version-check.outputs.target-version }}"
github_actor = "${{ github.actor }}"

# Validate inputs
version_pattern = r'^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9\-\.]+)?$'
if not re.match(version_pattern, target_version):
print(f"Invalid version format: {target_version}")
sys.exit(1)

# Sanitize actor name (basic validation)
if not re.match(r'^[a-zA-Z0-9\-_]+$', github_actor):
github_actor = "[sanitized]"

current_time = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')

print(f"**Successfully published secops v{target_version} to PyPI!**")
print("")
print(f"Package:** https://pypi.org/project/secops/{target_version}/")
print(f"Version:** {target_version}")
print(f"Published by:** {github_actor}")
print(f"Published at:** {current_time}")
print("")

# Write summary to GITHUB_OUTPUT for downstream jobs to use
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
simple_summary = f"Published secops v{target_version} to PyPI"
f.write(simple_summary)
shell: python