treemapper CD #56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .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-* |