Skip to content

treemapper CD

treemapper CD #56

Workflow file for this run

# .github/workflows/cd.yml
name: treemapper CD
permissions: {}
concurrency:
group: release
cancel-in-progress: false
'on':
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
publish_to_pypi:
description: 'Publish to PyPI'
required: true
default: 'false'
type: choice
options:
- 'true'
- 'false'
jobs:
prepare-version:
name: Prepare Version Commit
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set_outputs.outputs.version }}
tag_name: ${{ steps.set_outputs.outputs.tag_name }}
commit_sha: ${{ steps.commit_version.outputs.commit_sha }}
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need full history for git bundle
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Check that we're on main branch
env:
CURRENT_BRANCH: ${{ github.ref_name }}
run: |
if [ "$CURRENT_BRANCH" != "main" ]; then
echo "Error: Releases can only be created from the main branch. Current branch: $CURRENT_BRANCH"
exit 1
fi
- name: Validate version format (PEP 440)
env:
VERSION: ${{ github.event.inputs.version }}
run: |
# PEP 440: X.Y.Z with optional pre-release (a1, b1, rc1) or dev/post suffix
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.dev[0-9]+)?(\.post[0-9]+)?$ ]]; then
echo "Error: Invalid version format '$VERSION'."
echo "Expected PEP 440 format: 1.0.0, 1.0.0a1, 1.0.0b1, 1.0.0rc1, 1.0.0.dev1, 1.0.0.post1"
exit 1
fi
echo "Version format valid (PEP 440): $VERSION"
- name: Check version is not already set
env:
VERSION: ${{ github.event.inputs.version }}
run: |
CURRENT=$(python - <<'PY'
import re
content = open('src/treemapper/version.py').read()
m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
print(m.group(1) if m else '')
PY
)
if [ "$CURRENT" = "$VERSION" ]; then
echo "Error: version.py already contains version $VERSION"
echo "Nothing to release - version is already set."
exit 1
fi
echo "Current version: $CURRENT -> New version: $VERSION"
- name: Set version in version.py
env:
VERSION: ${{ github.event.inputs.version }}
run: |
echo "Setting version to $VERSION"
python - <<'PY'
import os, re, pathlib
ver = os.environ["VERSION"]
p = pathlib.Path("src/treemapper/version.py")
s = p.read_text(encoding="utf-8")
s, n = re.subn(r'__version__\s*=\s*["\'].*?["\']', f'__version__ = "{ver}"', s, count=1)
assert n == 1, "version assignment not found or multiple matches"
p.write_text(s, encoding="utf-8")
PY
echo "version.py content after change:"
cat src/treemapper/version.py
- name: Commit version bump (locally only, no push yet)
id: commit_version
env:
VERSION: ${{ github.event.inputs.version }}
run: |
git config user.name "github-actions[bot]"
# 41898282 is GitHub's bot user ID for github-actions[bot]
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add src/treemapper/version.py
if ! git diff --staged --quiet; then
git commit -m "Release version ${VERSION}"
else
echo "No changes to commit."
fi
COMMIT_SHA=$(git rev-parse HEAD)
echo "Commit SHA: $COMMIT_SHA"
echo "commit_sha=$COMMIT_SHA" >> "$GITHUB_OUTPUT"
- name: Check tag doesn't already exist
env:
VERSION: ${{ github.event.inputs.version }}
run: |
TAG="v${VERSION}"
git fetch --tags origin 2>/dev/null || true
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Error: Tag $TAG already exists locally"
exit 1
fi
if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
echo "Error: Tag $TAG already exists on remote"
exit 1
fi
echo "Tag $TAG does not exist, proceeding..."
- name: Create local tag (no push yet)
env:
VERSION: ${{ github.event.inputs.version }}
run: |
git tag -a "v${VERSION}" -m "Release version ${VERSION}"
echo "Tag created locally: v${VERSION}"
- name: Upload git bundle for other jobs
run: |
git bundle create repo.bundle --all
- name: Upload bundle as artifact
uses: actions/upload-artifact@v7
with:
name: git-repo-bundle
path: repo.bundle
retention-days: 1
- name: Set outputs
id: set_outputs
env:
VERSION: ${{ github.event.inputs.version }}
run: |
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag_name=v${VERSION}" >> "$GITHUB_OUTPUT"
build-assets:
name: Build Assets
needs: prepare-version
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
asset_name: linux
python-version: '3.11'
- os: macos-latest
asset_name: macos
python-version: '3.11'
- os: windows-latest
asset_name: windows
python-version: '3.11'
runs-on: ${{ matrix.os }}
steps:
- name: Download git bundle
uses: actions/download-artifact@v8
with:
name: git-repo-bundle
- name: Restore repository from bundle
shell: bash
env:
TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }}
run: |
git clone repo.bundle repo
cd repo
git checkout "$TAG_NAME"
# Debug: verify we're in the right state
echo "Current directory: $(pwd)"
echo "Current tag: $(git describe --tags --exact-match 2>/dev/null || echo 'no tag')"
echo "Current commit: $(git rev-parse HEAD)"
echo "Version file content:"
cat src/treemapper/version.py || echo "ERROR: version.py not found!"
echo "Directory structure:"
ls -la src/treemapper/ || echo "ERROR: src/treemapper not found!"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: './repo/pyproject.toml'
- name: Install Dependencies (including PyInstaller)
shell: bash
working-directory: ./repo
env:
EXPECTED_VERSION: ${{ needs.prepare-version.outputs.version }}
run: |
python -m pip install --upgrade pip
# Install in editable mode to ensure version.py changes are picked up
pip install -e .[dev]
# Verify the installed version matches what we expect
echo "Verifying installed version:"
python -c "from treemapper.version import __version__; print(f'Version in module: {__version__}')"
echo "Expected version: ${EXPECTED_VERSION}"
- name: Build with PyInstaller
shell: bash
working-directory: ./repo
env:
ASSET_DIR: ${{ matrix.asset_name }}
run: |
# Ensure PyInstaller can find the modules (use correct path separator for OS)
SEP=$(python -c "import os; print(os.pathsep)")
export PYTHONPATH="${PWD}/src${SEP}${PYTHONPATH:-}"
echo "PYTHONPATH: $PYTHONPATH"
# Run PyInstaller with explicit paths
python -m PyInstaller --clean -y --distpath "./dist/${ASSET_DIR}" treemapper.spec
# Verify the built executable exists (cross-platform)
echo "Checking for built executable in: ./dist/${ASSET_DIR}/"
ls -la "./dist/${ASSET_DIR}/" || echo "ERROR: dist directory not found!"
- name: Determine architecture
id: arch
shell: bash
env:
RUNNER_PLATFORM: ${{ runner.os }}
RUNNER_ARCHITECTURE: ${{ runner.arch }}
run: |
ARCH=$(uname -m)
if [[ "$RUNNER_PLATFORM" == "Windows" ]]; then
if [[ "$RUNNER_ARCHITECTURE" == "X64" ]]; then ARCH="x86_64"; \
elif [[ "$RUNNER_ARCHITECTURE" == "ARM64" ]]; then ARCH="arm64"; \
else ARCH="unknown"; fi
elif [[ "$RUNNER_PLATFORM" == "macOS" ]] && [[ "$ARCH" == "arm64" ]]; then
echo "Detected ARM on macOS"
fi
echo "Determined ARCH: $ARCH"
echo "arch=$ARCH" >> "$GITHUB_OUTPUT"
- name: Rename asset with proper name
shell: bash
working-directory: ./repo
env:
ASSET_DIR: ${{ matrix.asset_name }}
ARCH: ${{ steps.arch.outputs.arch }}
VERSION: ${{ needs.prepare-version.outputs.version }}
RUNNER_PLATFORM: ${{ runner.os }}
run: |
ASSET_NAME="treemapper-${ASSET_DIR}-${ARCH}-${VERSION}"
if [[ "$RUNNER_PLATFORM" == "Windows" ]]; then
ASSET_NAME="${ASSET_NAME}.exe"
mv "./dist/${ASSET_DIR}/treemapper.exe" "./dist/${ASSET_NAME}"
else
mv "./dist/${ASSET_DIR}/treemapper" "./dist/${ASSET_NAME}"
fi
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.asset_name }}-binary
path: ./repo/dist/treemapper-*
retention-days: 1
publish-to-pypi:
name: Publish to PyPI
needs: [prepare-version, build-assets]
if: github.event.inputs.publish_to_pypi == 'true'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/treemapper
permissions:
id-token: write
steps:
- name: Download git bundle
uses: actions/download-artifact@v8
with:
name: git-repo-bundle
- name: Restore repository from bundle
shell: bash
env:
TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }}
run: |
git clone repo.bundle repo
cd repo
git checkout "$TAG_NAME"
# Debug: verify we're in the right state
echo "Current directory: $(pwd)"
echo "Current tag: $(git describe --tags --exact-match 2>/dev/null || echo 'no tag')"
echo "Current commit: $(git rev-parse HEAD)"
echo "Version file content:"
cat src/treemapper/version.py || echo "ERROR: version.py not found!"
echo "Directory structure:"
ls -la src/treemapper/ || echo "ERROR: src/treemapper not found!"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install build tools
working-directory: ./repo
env:
EXPECTED_VERSION: ${{ needs.prepare-version.outputs.version }}
run: |
python -m pip install --upgrade pip
pip install build
# Verify version.py has the correct version
echo "Version in version.py:"
cat src/treemapper/version.py
echo "Expected version: ${EXPECTED_VERSION}"
- name: Build sdist and wheel
working-directory: ./repo
env:
VERSION: ${{ needs.prepare-version.outputs.version }}
run: |
# Build the distribution packages
python -m build
# Verify the built packages have correct version in filename
echo "Built packages:"
ls -la dist/
if [ -z "$VERSION" ]; then
echo "ERROR: VERSION is empty"
exit 1
fi
echo "Looking for version ${VERSION} in package names..."
ls dist/*"${VERSION}"* || echo "WARNING: No packages found!"
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
with:
packages-dir: ./repo/dist/
print-hash: true
finalize-release:
name: Push Release and Create GitHub Release
needs: [prepare-version, build-assets, publish-to-pypi]
if: |
needs.prepare-version.result == 'success' &&
needs.build-assets.result == 'success' &&
(needs.publish-to-pypi.result == 'success' ||
needs.publish-to-pypi.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download git bundle
uses: actions/download-artifact@v8
with:
name: git-repo-bundle
- name: Restore repository from bundle
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ needs.prepare-version.outputs.commit_sha }}
REPO_FULL_NAME: ${{ github.repository }}
run: |
git clone repo.bundle repo
cd repo
# Checkout the version commit (not old main) to preserve version bump
git checkout "$COMMIT_SHA"
echo "Current commit: $(git rev-parse HEAD)"
echo "Expected commit: ${COMMIT_SHA}"
# Set up remote to point to the actual GitHub repository
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO_FULL_NAME}.git"
- name: Push commit and tag to main
working-directory: ./repo
env:
TAG_NAME: ${{ needs.prepare-version.outputs.tag_name }}
run: |
git config user.name "github-actions[bot]"
# 41898282 is GitHub's bot user ID for github-actions[bot]
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Fetch latest state of remote main
git fetch origin main
# Check that remote main hasn't moved since we started the release
# Our commit should be based on the current remote main HEAD
REMOTE_MAIN=$(git rev-parse origin/main)
OUR_PARENT=$(git rev-parse HEAD^)
if [ "$REMOTE_MAIN" != "$OUR_PARENT" ]; then
echo "Error: Remote main has changed since release started."
echo "Expected parent: $OUR_PARENT"
echo "Remote main: $REMOTE_MAIN"
echo "Please re-run the release workflow."
exit 1
fi
# Push the version bump commit to main
git push origin HEAD:main
# Push the tag
git push origin "$TAG_NAME"
echo "Successfully pushed version commit and tag"
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: ./artifacts
- name: Create GitHub Release with assets
uses: softprops/action-gh-release@1853d73993c8ca1b2c9c1a7fede39682d0ab5c2a # v2
with:
tag_name: ${{ needs.prepare-version.outputs.tag_name }}
name: Release ${{ needs.prepare-version.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
files: ./artifacts/**/*-binary/treemapper-*