name: Release Workflow
on:
workflow_dispatch:
inputs:
bump_type:
description: "Type of version bump to perform"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
pre_release:
description: "Pre-release label (optional)"
required: false
type: string
dry_run:
description: "Dry run (no changes will be committed)"
required: false
default: false
type: boolean
permissions:
contents: write
packages: write
actions: read
jobs:
prepare-release:
name: Prepare Release
timeout-minutes: 12
runs-on: ubuntu-latest
outputs:
current_version: ${{ steps.get_version.outputs.current_version }}
new_version: ${{ steps.bump_version.outputs.new_version }}
release_created: ${{ steps.create_release.outputs.release_created }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install toml semver pyyaml pre-commit
- name: Get current version
id: get_version
run: |
VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])")
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Bump version
id: bump_version
run: |
CURRENT_VERSION="${{ steps.get_version.outputs.current_version }}"
BUMP_TYPE="${{ github.event.inputs.bump_type }}"
PRE_RELEASE="${{ github.event.inputs.pre_release }}"
echo "Bumping $BUMP_TYPE version from $CURRENT_VERSION"
# Use semver to calculate new version
if [ -n "$PRE_RELEASE" ]; then
NEW_VERSION=$(python -c "import semver; print(str(semver.VersionInfo.parse('$CURRENT_VERSION').bump_$BUMP_TYPE().replace(prerelease='$PRE_RELEASE')))")
else
NEW_VERSION=$(python -c "import semver; print(str(semver.VersionInfo.parse('$CURRENT_VERSION').bump_$BUMP_TYPE()))")
fi
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.event.inputs.dry_run }}" != "true" ]; then
# Update version in pyproject.toml
python -c "
import toml, pathlib
data = toml.load('pyproject.toml')
data['project']['version'] = '$NEW_VERSION'
path = pathlib.Path('pyproject.toml')
path.write_text(toml.dumps(data))
"
# Update version in __init__.py
if [ -f "simplenote_mcp/__init__.py" ]; then
sed -i "s/__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/g" simplenote_mcp/__init__.py
fi
fi
- name: Generate changelog
id: changelog
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
# Get last tag for changelog generation
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "Generating changelog using conventional commit parser..."
# Use the conventional changelog script
if [ -n "$LAST_TAG" ]; then
echo "Generating changelog since $LAST_TAG"
python scripts/conventional_changelog.py \
--since "$LAST_TAG" \
--version "${{ steps.bump_version.outputs.new_version }}" \
--output changelog.md
else
echo "No previous tag found, generating changelog from all commits"
python scripts/conventional_changelog.py \
--version "${{ steps.bump_version.outputs.new_version }}" \
--output changelog.md
fi
echo "Generated changelog:"
cat changelog.md
# Generate summary statistics
echo ""
echo "Changelog statistics:"
python scripts/conventional_changelog.py \
$([ -n "$LAST_TAG" ] && echo "--since $LAST_TAG") \
--format summary
# Format changelog for GitHub release
CHANGELOG_CONTENT=$(cat changelog.md)
CHANGELOG_CONTENT="${CHANGELOG_CONTENT//'%'/'%25'}"
CHANGELOG_CONTENT="${CHANGELOG_CONTENT//$'\n'/'%0A'}"
CHANGELOG_CONTENT="${CHANGELOG_CONTENT//$'\r'/'%0D'}"
echo "changelog=$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT
- name: Commit version bump
id: commit
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add pyproject.toml simplenote_mcp/__init__.py changelog.md || true
git commit -m "Bump version to ${{ steps.bump_version.outputs.new_version }}" || echo "No changes to commit"
git tag -a v${{ steps.bump_version.outputs.new_version }} -m "Version ${{ steps.bump_version.outputs.new_version }}"
echo "commit_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Push changes
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
git push origin main
git push origin v${{ steps.bump_version.outputs.new_version }}
- name: Create GitHub Release
id: create_release
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: softprops/action-gh-release@v2
with:
name: "v${{ steps.bump_version.outputs.new_version }}"
tag_name: "v${{ steps.bump_version.outputs.new_version }}"
body: ${{ steps.changelog.outputs.changelog }}
prerelease: ${{ github.event.inputs.pre_release != '' }}
draft: false
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload changelog artifact
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/upload-artifact@v6
with:
name: changelog-${{ steps.bump_version.outputs.new_version }}
path: changelog.md
retention-days: 30
- name: Set release creation output
if: ${{ github.event.inputs.dry_run != 'true' }}
run: echo "release_created=true" >> $GITHUB_OUTPUT
build-and-publish:
timeout-minutes: 10
name: Build and Publish Package
needs: prepare-release
if: ${{ needs.prepare-release.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
ref: v${{ needs.prepare-release.outputs.new_version }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine pre-commit
- name: Detect PyPI token
id: detect_pypi
run: |
if [ -n "${PYPI_API_TOKEN}" ]; then
echo "has_token=true" >> $GITHUB_OUTPUT
else
echo "has_token=false" >> $GITHUB_OUTPUT
fi
env:
PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
- name: Run pre-commit checks
run: |
pre-commit run --all-files
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Generate SBOM and vulnerability report
run: |
echo "📋 Generating SBOM and vulnerability reports for release..."
# Install required tools
pip install cyclonedx-bom==7.0.0 pip-audit==2.7.3
# Generate vulnerability report
echo "🔍 Generating vulnerability report..."
pip-audit -r pyproject.toml -f json -o vulnerability-report.json || true
pip-audit -r pyproject.toml -f markdown -o vulnerability-report.md || true
# Generate SBOM in multiple formats
echo "📋 Generating Software Bill of Materials..."
cyclonedx-py -o sbom-release.json -F json --install-all-packages
cyclonedx-py -o sbom-release.xml -F xml --install-all-packages
# Generate additional SBOM from pip-audit
pip-audit -r pyproject.toml -f cyclonedx-json -o sbom-pip-audit-release.json || true
# Generate release-specific simple SBOM
echo "📋 Generating release SBOM..."
python - <<'EOF'
import json
import subprocess
import sys
from datetime import datetime
def get_package_info():
"""Get installed package information."""
try:
result = subprocess.run(
[sys.executable, '-m', 'pip', 'list', '--format=json'],
capture_output=True, text=True, check=True
)
return json.loads(result.stdout)
except Exception as e:
print(f"Error getting package info: {e}")
return []
# Generate release SBOM with version info
packages = get_package_info()
sbom = {
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:simplenote-mcp-server-release-${{ needs.prepare-release.outputs.new_version }}",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"tools": [
{
"vendor": "Simplenote MCP Server",
"name": "Release SBOM Generator",
"version": "${{ needs.prepare-release.outputs.new_version }}"
}
],
"component": {
"type": "application",
"name": "simplenote-mcp-server",
"version": "${{ needs.prepare-release.outputs.new_version }}",
"description": "Model Context Protocol (MCP) server for Simplenote"
}
},
"components": []
}
# Add components
for package in packages:
component = {
"type": "library",
"name": package["name"],
"version": package["version"],
"purl": f"pkg:pypi/{package['name']}@{package['version']}",
"scope": "required"
}
sbom["components"].append(component)
with open('sbom-release-simple.json', 'w') as f:
json.dump(sbom, f, indent=2)
print(f"Generated SBOM with {len(sbom['components'])} components")
EOF
# Create vulnerability summary
echo "📊 Creating vulnerability summary..."
python - <<'EOF'
import json
import os
try:
with open('vulnerability-report.json', 'r') as f:
report = json.load(f)
vulnerabilities = report.get('vulnerabilities', [])
# Create summary
summary = {
"scan_date": report.get('metadata', {}).get('timestamp', 'unknown'),
"total_vulnerabilities": len(vulnerabilities),
"severity_counts": {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"unknown": 0
},
"affected_packages": []
}
for vuln in vulnerabilities:
# Extract severity information
severity = 'unknown'
aliases = vuln.get('aliases', [])
for alias in aliases:
if 'critical' in alias.lower():
severity = 'critical'
break
elif 'high' in alias.lower():
severity = 'high'
break
elif 'medium' in alias.lower():
severity = 'medium'
break
elif 'low' in alias.lower():
severity = 'low'
break
summary["severity_counts"][severity] += 1
# Track affected packages
package = vuln.get('package', 'unknown')
if package not in summary["affected_packages"]:
summary["affected_packages"].append(package)
with open('vulnerability-summary.json', 'w') as f:
json.dump(summary, f, indent=2)
print(f"Vulnerability Summary:")
print(f" Total: {summary['total_vulnerabilities']}")
print(f" Critical: {summary['severity_counts']['critical']}")
print(f" High: {summary['severity_counts']['high']}")
print(f" Medium: {summary['severity_counts']['medium']}")
print(f" Low: {summary['severity_counts']['low']}")
print(f" Affected packages: {len(summary['affected_packages'])}")
except FileNotFoundError:
print("No vulnerability report found - creating empty summary")
summary = {
"scan_date": "unknown",
"total_vulnerabilities": 0,
"severity_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0},
"affected_packages": []
}
with open('vulnerability-summary.json', 'w') as f:
json.dump(summary, f, indent=2)
EOF
# List generated files
echo "📋 Generated files:"
ls -la sbom-*.json sbom-*.xml vulnerability-*.json vulnerability-*.md 2>/dev/null || true
echo "✅ SBOM and vulnerability report generation completed"
- name: Upload SBOM and vulnerability artifacts
uses: actions/upload-artifact@v6
with:
name: security-reports-${{ needs.prepare-release.outputs.new_version }}
path: |
sbom-*.json
sbom-*.xml
vulnerability-*.json
vulnerability-*.md
retention-days: 90
- name: Publish to PyPI
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
skip-existing: false
- name: Post-publish summary
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
echo "Publish step executed. Verify on PyPI to confirm success."
attach-security-reports:
timeout-minutes: 5
name: Attach Security Reports to Release
needs: [prepare-release, build-and-publish]
if: ${{ needs.prepare-release.outputs.release_created == 'true' && needs.build-and-publish.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Download security artifacts
uses: actions/download-artifact@v7
with:
name: security-reports-${{ needs.prepare-release.outputs.new_version }}
path: security-reports/
- name: List downloaded files
run: |
echo "📋 Downloaded security reports:"
ls -la security-reports/
- name: Attach SBOM and vulnerability reports to release
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ needs.prepare-release.outputs.new_version }}"
files: |
security-reports/sbom-release.json
security-reports/sbom-release.xml
security-reports/sbom-release-simple.json
security-reports/vulnerability-report.json
security-reports/vulnerability-report.md
security-reports/vulnerability-summary.json
token: ${{ secrets.GITHUB_TOKEN }}
notify:
timeout-minutes: 5
name: Notification
needs: [prepare-release, build-and-publish, attach-security-reports]
if: ${{ always() && needs.prepare-release.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Send success notification
if: ${{ needs.build-and-publish.result == 'success' }}
run: |
echo "Version ${{ needs.prepare-release.outputs.new_version }} was successfully released!"
# Add notification mechanism here if needed (Slack, email, etc.)
- name: Send failure notification
if: ${{ needs.build-and-publish.result != 'success' }}
run: |
echo "Release process failed at the build/publish stage."
# Add notification mechanism here if needed (Slack, email, etc.)