name: Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
id-token: write
packages: write
# Ensure only one release runs at a time
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ==========================================
# Pre-Release Validation
# ==========================================
validate-release:
name: Validate Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
is-prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
TAG=${GITHUB_REF#refs/tags/}
VERSION=${TAG#v}
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
# Check if this is a prerelease (contains rc, alpha, beta)
if [[ $VERSION == *"rc"* ]] || [[ $VERSION == *"alpha"* ]] || [[ $VERSION == *"beta"* ]]; then
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Validate tag format
run: |
TAG="${{ steps.version.outputs.tag }}"
if [[ ! $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then
echo "❌ Invalid tag format: $TAG"
echo "Expected format: v1.2.3 or v1.2.3-rc.1"
exit 1
fi
echo "✅ Valid tag format: $TAG"
- name: Check if tag already exists in releases
run: |
TAG="${{ steps.version.outputs.tag }}"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "❌ Release for tag $TAG already exists"
exit 1
fi
echo "✅ New release tag: $TAG"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ==========================================
# Quality Gates - Must Pass
# ==========================================
quality-gates:
name: Release Quality Gates
runs-on: ubuntu-latest
needs: validate-release
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install -e ".[dev]"
- name: Format check
run: ruff format --check
- name: Lint check
run: ruff check
- name: Type check
run: mypy markitdown_mcp/ --strict
- name: Run tests with coverage
run: |
pytest --cov=markitdown_mcp --cov-report=json --cov-fail-under=80 \
tests/unit/ tests/integration/test_mcp_protocol_smoke.py
- name: MCP protocol validation
run: |
python scripts/generate-schemas.py
# Verify MCP server starts and responds
timeout 30 python -c "
import asyncio
from markitdown_mcp.server import MarkItDownMCPServer, MCPRequest
async def test():
server = MarkItDownMCPServer()
req = MCPRequest(id='test', method='tools/list', params={})
resp = await server.handle_request(req)
assert resp.result, 'MCP server failed to respond'
tools = resp.result['tools']
assert len(tools) >= 3, f'Expected 3+ tools, got {len(tools)}'
print(f'✅ MCP validation passed: {len(tools)} tools available')
asyncio.run(test())
"
# ==========================================
# Security Validation
# ==========================================
security-scan:
name: Security Validation
runs-on: ubuntu-latest
needs: validate-release
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install security tools
run: |
pip install bandit safety
- name: Security scan with Bandit
run: |
bandit -r markitdown_mcp/ -f json -o bandit-results.json
python -c "
import json
with open('bandit-results.json') as f:
results = json.load(f)
high_issues = [r for r in results.get('results', []) if r['issue_severity'] == 'HIGH']
if high_issues:
print(f'❌ Found {len(high_issues)} HIGH severity security issues')
for issue in high_issues[:3]:
print(f' - {issue[\"test_name\"]}: {issue[\"issue_text\"]}')
exit(1)
print('✅ No high-severity security issues found')
"
- name: Dependency vulnerability scan
run: |
safety check --json || true
# ==========================================
# Build and Test Package
# ==========================================
build-package:
name: Build Package
runs-on: ubuntu-latest
needs: [validate-release, quality-gates, security-scan]
outputs:
package-name: ${{ steps.build.outputs.package-name }}
package-version: ${{ steps.build.outputs.package-version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build tools
run: |
pip install build twine tomli_w
- name: Build package
id: build
run: |
# Update version in pyproject.toml to match tag
VERSION="${{ needs.validate-release.outputs.version }}"
sed -i "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml
# Build package
python -m build
# Extract package info
PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])")
echo "package-name=$PACKAGE_NAME" >> $GITHUB_OUTPUT
echo "package-version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Built package: $PACKAGE_NAME v$VERSION"
- name: Verify package
run: |
# Check package contents
twine check dist/*
# Install and test package
pip install dist/*.whl
# Test basic functionality
python -c "
import markitdown_mcp
print(f'✅ Package installed successfully: {markitdown_mcp.__version__}')
"
# Test MCP server
echo '{}' | markitdown-mcp || echo "⚠️ MCP server test skipped"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ needs.validate-release.outputs.version }}
path: dist/
retention-days: 30
# ==========================================
# Generate Changelog
# ==========================================
generate-changelog:
name: Generate Changelog
runs-on: ubuntu-latest
needs: validate-release
outputs:
changelog: ${{ steps.changelog.outputs.changelog }}
release-notes: ${{ steps.changelog.outputs.release-notes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog from commits
id: changelog
run: |
# Get previous tag for changelog generation
CURRENT_TAG="${{ needs.validate-release.outputs.tag }}"
PREVIOUS_TAG=$(git describe --tags --abbrev=0 $CURRENT_TAG^ 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
echo "First release - using all commits"
COMMIT_RANGE="HEAD"
else
echo "Previous tag: $PREVIOUS_TAG"
COMMIT_RANGE="$PREVIOUS_TAG..$CURRENT_TAG"
fi
# Generate changelog from conventional commits
python -c "
import subprocess
import re
from datetime import datetime
# Get commits in range
result = subprocess.run(['git', 'log', '--oneline', '--pretty=format:%s', '$COMMIT_RANGE'],
capture_output=True, text=True)
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
# Parse conventional commits
features = []
fixes = []
breaking = []
other = []
for commit in commits:
if not commit.strip():
continue
# Check for breaking changes
if 'BREAKING CHANGE' in commit.upper():
breaking.append(commit)
elif commit.startswith('feat'):
features.append(commit)
elif commit.startswith('fix'):
fixes.append(commit)
elif commit.startswith(('docs', 'test', 'chore', 'ci', 'refactor', 'perf')):
other.append(commit)
# Generate changelog
changelog = []
changelog.append(f'## [{\"${{ needs.validate-release.outputs.version }}\"}] - {datetime.now().strftime(\"%Y-%m-%d\")}')
changelog.append('')
if breaking:
changelog.append('### ⚠️ BREAKING CHANGES')
for commit in breaking:
desc = re.sub(r'^[^:]+:', '', commit).strip()
changelog.append(f'- {desc}')
changelog.append('')
if features:
changelog.append('### ✨ Features')
for commit in features:
desc = re.sub(r'^feat[^:]*:', '', commit).strip()
changelog.append(f'- {desc}')
changelog.append('')
if fixes:
changelog.append('### 🐛 Bug Fixes')
for commit in fixes:
desc = re.sub(r'^fix[^:]*:', '', commit).strip()
changelog.append(f'- {desc}')
changelog.append('')
if other:
changelog.append('### 🔧 Other Changes')
for commit in other[:5]: # Limit other changes
desc = re.sub(r'^[^:]+:', '', commit).strip()
changelog.append(f'- {desc}')
if len(other) > 5:
changelog.append(f'- ... and {len(other) - 5} more changes')
changelog.append('')
changelog_text = '\n'.join(changelog)
# Create release notes (shorter version for GitHub)
release_notes = []
if breaking:
release_notes.append('### ⚠️ BREAKING CHANGES')
for commit in breaking[:3]:
desc = re.sub(r'^[^:]+:', '', commit).strip()
release_notes.append(f'- {desc}')
release_notes.append('')
if features:
release_notes.append('### ✨ New Features')
for commit in features[:5]:
desc = re.sub(r'^feat[^:]*:', '', commit).strip()
release_notes.append(f'- {desc}')
if len(features) > 5:
release_notes.append(f'- ... and {len(features) - 5} more features')
release_notes.append('')
if fixes:
release_notes.append('### 🐛 Bug Fixes')
for commit in fixes[:5]:
desc = re.sub(r'^fix[^:]*:', '', commit).strip()
release_notes.append(f'- {desc}')
if len(fixes) > 5:
release_notes.append(f'- ... and {len(fixes) - 5} more fixes')
release_notes.append('')
release_notes.append('### 📊 Release Info')
release_notes.append(f'- **Total commits**: {len(commits)}')
release_notes.append(f'- **Features**: {len(features)}')
release_notes.append(f'- **Bug fixes**: {len(fixes)}')
if breaking:
release_notes.append(f'- **Breaking changes**: {len(breaking)}')
release_notes_text = '\n'.join(release_notes)
# Output to GitHub Actions
import os
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f'changelog<<EOF\n{changelog_text}\nEOF\n')
f.write(f'release-notes<<EOF\n{release_notes_text}\nEOF\n')
print('✅ Changelog generated successfully')
"
# ==========================================
# Publish to PyPI
# ==========================================
publish-pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: [validate-release, build-package, generate-changelog]
if: needs.validate-release.outputs.is-prerelease == 'false'
environment: release
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.validate-release.outputs.version }}
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
- name: Verify PyPI publication
run: |
sleep 30 # Wait for PyPI to update
pip install markitdown-mcp==${{ needs.validate-release.outputs.version }}
python -c "
import markitdown_mcp
print(f'✅ Successfully published to PyPI: v{markitdown_mcp.__version__}')
"
# ==========================================
# Create GitHub Release
# ==========================================
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [validate-release, build-package, generate-changelog, publish-pypi]
if: always() && needs.validate-release.result == 'success'
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.validate-release.outputs.version }}
path: dist/
- name: Create GitHub Release
run: |
TAG="${{ needs.validate-release.outputs.tag }}"
VERSION="${{ needs.validate-release.outputs.version }}"
IS_PRERELEASE="${{ needs.validate-release.outputs.is-prerelease }}"
# Create release
gh release create "$TAG" dist/* \
--title "Release $VERSION" \
--notes "${{ needs.generate-changelog.outputs.release-notes }}" \
$([ "$IS_PRERELEASE" = "true" ] && echo "--prerelease" || echo "") \
--verify-tag
echo "✅ GitHub release created: $TAG"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ==========================================
# Update Documentation
# ==========================================
update-docs:
name: Update Documentation
runs-on: ubuntu-latest
needs: [validate-release, generate-changelog, create-github-release]
if: needs.validate-release.outputs.is-prerelease == 'false'
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Update CHANGELOG.md
run: |
# Backup current changelog
cp CHANGELOG.md CHANGELOG.md.bak
# Add new version to changelog
{
echo "# Changelog"
echo ""
echo "All notable changes to this project will be documented in this file."
echo ""
echo "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),"
echo "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)."
echo ""
echo "${{ needs.generate-changelog.outputs.changelog }}"
echo ""
# Add existing changelog content (skip header)
tail -n +9 CHANGELOG.md.bak
} > CHANGELOG.md
# Clean up
rm CHANGELOG.md.bak
- name: Commit changelog update
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
if git diff --exit-code CHANGELOG.md; then
echo "No changelog changes to commit"
else
git add CHANGELOG.md
git commit -m "docs: update changelog for v${{ needs.validate-release.outputs.version }}"
git push origin main
echo "✅ Changelog updated"
fi
# ==========================================
# Post-Release Validation
# ==========================================
post-release-validation:
name: Post-Release Validation
runs-on: ubuntu-latest
needs: [validate-release, create-github-release, publish-pypi, update-docs]
if: always()
steps:
- name: Validate release completion
run: |
VERSION="${{ needs.validate-release.outputs.version }}"
TAG="${{ needs.validate-release.outputs.tag }}"
echo "🔍 Validating release completion for $TAG"
# Check GitHub release
if gh release view "$TAG" >/dev/null 2>&1; then
echo "✅ GitHub release exists"
else
echo "❌ GitHub release missing"
exit 1
fi
# Check PyPI (skip for prereleases)
if [[ "${{ needs.validate-release.outputs.is-prerelease }}" == "false" ]]; then
if pip index versions markitdown-mcp | grep -q "$VERSION"; then
echo "✅ PyPI package published"
else
echo "❌ PyPI package missing"
exit 1
fi
fi
echo "🎉 Release validation completed successfully!"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify on failure
if: failure()
run: |
echo "🚨 Release workflow failed for ${{ needs.validate-release.outputs.tag }}"
echo "Manual intervention may be required"
# In a real setup, this could send notifications to Slack/email/etc.