Skip to content
Merged
Show file tree
Hide file tree
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
258 changes: 258 additions & 0 deletions .github/workflows/promote-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
# promote-release.yml — promote a draft release to live and publish to PyPI.
#
# Triggered manually via GitHub UI or CLI:
#
# gh workflow run promote-release.yml -f tag=v1.0.0
#
# This workflow:
# 1. Verifies the tag and draft release exist
# 2. Downloads artifacts from the draft release
# 3. Publishes wheel + sdist to PyPI via OIDC
# 4. Creates a GitHub Deployment record (for tracking)
# 5. Un-drafts the GitHub Release
# 6. Generates and deploys versioned docs to GitHub Pages

name: Promote Release

on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to promote (e.g., v1.0.0)'
required: true
type: string

permissions: {} # no workflow-level permissions; each job declares its own

jobs:
# ── Validate tag, publish to PyPI, un-draft release ──────────────────
promote:
name: Promote to Live & Publish to PyPI
runs-on: ubuntu-latest
environment: pypi
outputs:
version: ${{ steps.version.outputs.version }}
permissions:
id-token: write
contents: write
deployments: write
steps:
- name: Checkout tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag }}
persist-credentials: false

- name: Validate tag and extract version
id: version
run: |
VERSION=$(python3 -c "
import tomllib
with open('pyproject.toml', 'rb') as f:
print(tomllib.load(f)['project']['version'])
")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
TAG_VERSION="${{ inputs.tag }}"
TAG_VERSION="${TAG_VERSION#v}"
if [ "$TAG_VERSION" != "$VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match pyproject.toml version ($VERSION)"
exit 1
fi
echo "✓ Tag ${{ inputs.tag }} matches version $VERSION"

- name: Check draft release exists
id: release
env:
GH_TOKEN: ${{ github.token }}
run: |
RELEASE_JSON=$(gh release view "${{ inputs.tag }}" --json isDraft,id 2>/dev/null || echo "{}")
IS_DRAFT=$(echo "$RELEASE_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin).get('isDraft', False))")
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
if [ "$IS_DRAFT" != "True" ] && [ "$RELEASE_ID" != "" ]; then
echo "::warning::Release ${{ inputs.tag }} is not a draft, but proceeding..."
fi
if [ -z "$RELEASE_ID" ]; then
echo "::error::Release ${{ inputs.tag }} not found"
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "✓ Draft release found (ID: $RELEASE_ID)"

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false

- name: Install Python
run: uv python install 3.14.2

- name: Download artifacts from release
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p dist/
gh release download "${{ inputs.tag }}" \
--pattern "*.whl" \
--pattern "*.tar.gz" \
--dir dist/
echo "Downloaded artifacts:"
ls -lh dist/

- name: Publish to PyPI
run: uv publish

- name: Verify package on PyPI
id: pypi-verify
run: |
echo "Waiting 30s for PyPI indexing..."
sleep 30
VERSION="${{ steps.version.outputs.version }}"
WHEEL_FILE=$(ls dist/*.whl | head -1)
WHEEL_NAME=$(basename "$WHEEL_FILE" .whl)
# Extract package name and version from wheel (format: {distribution}-{version}-...)
PKG_NAME=$(echo "$WHEEL_NAME" | cut -d- -f1 | tr '_' '-')
echo "Checking PyPI for $PKG_NAME==$VERSION..."
python3 -c "
import urllib.request
import json
try:
url = f'https://pypi.org/pypi/{urllib.parse.quote(\"$PKG_NAME\")}/json'
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
releases = data.get('releases', {})
if '${VERSION}' in releases:
print(f'✓ Package $PKG_NAME==${VERSION} found on PyPI')
else:
raise Exception(f'Version ${VERSION} not found. Available: {list(releases.keys())[-5:]}')
except Exception as e:
print(f'✗ PyPI verification failed: {e}')
exit(1)
" 2>&1 | tee /tmp/pypi-check.log
exit_code=${PIPESTATUS[0]}
result=$(cat /tmp/pypi-check.log)
echo "pypi_result=$result" >> "$GITHUB_OUTPUT"
exit $exit_code

- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: '${{ inputs.tag }}',
environment: 'production',
description: 'Release ${{ inputs.tag }} to PyPI',
auto_merge: false,
required_contexts: []
});
core.setOutput('deployment_id', deployment.data.id);
console.log(`✓ Created deployment ${deployment.data.id}`);

- name: Update Deployment Status (success)
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.deployment.outputs.deployment_id }},
state: 'success',
description: 'Package published to PyPI',
environment_url: 'https://pypi.org/project/bank-statement-parser/${{ steps.version.outputs.version }}/'
});
console.log('✓ Deployment marked as success');

- name: Un-draft the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "${{ inputs.tag }}" --draft=false
echo "✓ Release ${{ inputs.tag }} promoted to live"

# ── Generate and deploy docs to GitHub Pages ──────────────────────────
deploy-docs:
name: Generate & Deploy Docs
runs-on: ubuntu-latest
needs: promote
permissions:
contents: write
steps:
- name: Checkout release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag }}
persist-credentials: true

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false

- name: Install Python
run: uv python install 3.14.2

- name: Install dependencies
run: uv sync --group dev

- name: Generate docs for this release
run: uv run python scripts/generate_docs.py

- name: Build site with mkdocs
run: uv run zensical build --clean
env:
GOATCOUNTER_URL: ${{ secrets.GOATCOUNTER_URL }}

- name: Fetch existing gh-pages to avoid orphan push rejection
run: git fetch origin gh-pages:gh-pages --depth=1 || true

- name: Deploy current version with mike
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
uv run mike deploy --push --update-aliases \
"${{ needs.promote.outputs.version }}" latest

- name: Fetch all tags for retroactive updates
run: git fetch origin --tags --prune

- name: Retroactively update 2 prior versions
run: |
set -e

# Get all tags sorted by version (newest first), then take 2nd and 3rd
TAGS=$(git tag --sort=-version:refname)
PRIOR_VERSIONS=$(echo "$TAGS" | head -3 | tail -2)

for tag in $PRIOR_VERSIONS; do
version="${tag#v}"
echo "Retroactively updating docs for $version with current bank configs..."

# Stash any uncommitted changes
git stash || true

# Checkout the old tag to get its docs state (tag has v prefix)
git checkout "$tag" -- docs/

# Get the latest bank configs from master
git checkout origin/master -- src/bank_statement_parser/project/config/import/

# Regenerate docs with new bank data
uv run python scripts/generate_docs.py

# Deploy locally (no push) — mike creates a commit on local gh-pages
uv run mike deploy "$version"
done

# Create root index.html that redirects to latest/
uv run mike set-default latest

# Push all retroactive commits to remote in one go
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git push origin gh-pages

- name: Clean up
if: always()
run: git stash pop || true
Loading