Skip to content

Build and Publish Wheels #21

Build and Publish Wheels

Build and Publish Wheels #21

Workflow file for this run

# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025 py7zz contributors
name: Build and Publish Wheels
on:
push:
tags:
- 'v*' # Only trigger on tags starting with 'v'
workflow_dispatch:
inputs:
tag:
description: 'Tag to build (e.g., v1.0.0, v1.0.0a1, v1.0.0b1, v1.0.0rc1)'
required: true
type: string
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux
arch: x86_64
binary_name: 7zz
wheel_tag: manylinux1_x86_64
- os: macos-latest # Universal2 runner
platform: macos
arch: universal2
binary_name: 7zz
wheel_tag: macosx_10_9_universal2
- os: windows-latest
platform: windows
arch: x86_64
binary_name: 7zz.exe
wheel_tag: win_amd64
steps:
- uses: actions/checkout@v4
- name: Verify CI status
shell: bash
run: |
echo "Verifying CI workflow status for commit: $GITHUB_SHA"
# Query CI workflow status for the specific commit using gh run list
# This avoids Windows Git Bash path rewriting issues with gh api
CI_STATUS=$(gh run list \
--workflow="CI" \
--json conclusion,headSha \
--jq ".[] | select(.headSha == \"$GITHUB_SHA\") | .conclusion" \
| head -1)
echo "CI workflow status: $CI_STATUS"
if [ "$CI_STATUS" = "success" ]; then
echo "✅ CI workflow passed - proceeding with build"
elif [ "$CI_STATUS" = "failure" ]; then
echo "❌ CI workflow failed - aborting build"
echo "Please ensure all CI checks pass before building"
exit 1
elif [ -z "$CI_STATUS" ]; then
echo "⚠️ No CI workflow found for this commit"
echo "This may indicate the commit was not properly tested"
echo "Please ensure CI workflow runs and passes before building"
exit 1
else
echo "⏳ CI workflow status: $CI_STATUS"
echo "Please wait for CI workflow to complete successfully before building"
exit 1
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest" # Use latest version for better lock file support
timeout-minutes: 3 # Add timeout to prevent hanging
- name: Set up Python
run: uv python install 3.11
- name: Validate tag format and determine release type
id: validate_tag
shell: bash
run: |
# Get tag from push event or workflow dispatch
if [ "${{ github.event.inputs.tag }}" != "" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG=$(echo ${{ github.ref }} | sed 's/refs\/tags\///')
fi
echo "Git tag: $TAG"
# Validate tag format (PEP 440 compliant)
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(a[0-9]+|b[0-9]+|rc[0-9]+)?$ ]]; then
echo "✓ Tag format is valid: $TAG"
else
echo "✗ Invalid tag format: $TAG"
echo "Expected format: v{major}.{minor}.{patch}[a{N}|b{N}|rc{N}]"
echo "Examples: v1.0.0, v1.0.0a1, v1.0.0b1, v1.0.0rc1"
exit 1
fi
# Determine if this is a stable release (no suffix) or pre-release
if [[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
RELEASE_TYPE="stable"
echo "Release type: Stable"
else
RELEASE_TYPE="pre-release"
echo "Release type: Pre-release"
fi
# Get 7zz version from locked file
echo "Reading configured 7-Zip version..."
chmod +x scripts/get_7zz.sh
SEVEN_ZZ_VERSION=$(./scripts/get_7zz.sh --get-current)
echo "Configured 7zz version: $SEVEN_ZZ_VERSION"
# Set outputs
echo "git_tag=$TAG" >> $GITHUB_OUTPUT
echo "seven_zz_version=$SEVEN_ZZ_VERSION" >> $GITHUB_OUTPUT
echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT
- name: Validate stable release ancestry (for stable releases only)
if: steps.validate_tag.outputs.release_type == 'stable'
shell: bash
run: |
echo "Validating stable release ancestry..."
# Fetch origin/main to ensure we have latest main branch
git fetch origin main
# Check if current tag commit is an ancestor of main
if git merge-base --is-ancestor $GITHUB_SHA origin/main; then
echo "✓ Tag commit is an ancestor of main branch - valid for stable release"
else
echo "✗ Tag commit is not an ancestor of main branch - stable releases must come from main"
echo "Current commit: $GITHUB_SHA"
echo "Main branch head: $(git rev-parse origin/main)"
exit 1
fi
- name: Download 7zz binary using unified script
shell: bash
run: |
VERSION="${{ steps.validate_tag.outputs.seven_zz_version }}"
PLATFORM="${{ matrix.platform }}"
ARCH="${{ matrix.arch }}"
BINARY_NAME="${{ matrix.binary_name }}"
echo "Using unified 7zz download script..."
echo "Platform: $PLATFORM"
echo "Architecture: $ARCH"
echo "Version: $VERSION"
# Make script executable and run it
chmod +x scripts/get_7zz.sh
./scripts/get_7zz.sh --os "$PLATFORM" --arch "$ARCH" --version "$VERSION"
# Verify the binary was downloaded correctly
echo "Verifying binary placement..."
if [[ -f "py7zz/bin/${BINARY_NAME}" ]]; then
echo "✓ Binary correctly placed at: py7zz/bin/${BINARY_NAME}"
ls -la "py7zz/bin/"
# Show file type information
file "py7zz/bin/${BINARY_NAME}" || echo "file command not available"
# For macOS, show architecture info
if [[ "$PLATFORM" == "macos" ]]; then
lipo -info "py7zz/bin/${BINARY_NAME}" 2>/dev/null || echo "Single architecture binary (expected)"
fi
else
echo "✗ Binary not found at expected location: py7zz/bin/${BINARY_NAME}"
echo "Contents of py7zz/bin/:"
find py7zz/bin/ -type f -ls 2>/dev/null || echo "No files found"
exit 1
fi
# Test binary functionality
echo "Testing binary functionality..."
if "py7zz/bin/${BINARY_NAME}" --help > /dev/null 2>&1; then
echo "✓ Binary functionality test passed"
else
echo "✗ Binary functionality test failed"
echo "This may be normal for some platforms due to dependencies or signing requirements"
fi
# For Windows, verify both 7zz.exe and 7z.dll are present
if [[ "$PLATFORM" == "windows" ]]; then
if [[ -f "py7zz/bin/7z.dll" ]]; then
echo "✓ Windows 7z.dll found for complete functionality"
else
echo "⚠️ Windows 7z.dll not found - some formats may not be supported"
fi
fi
- name: Install dependencies
timeout-minutes: 5 # Add timeout for dependency installation
run: |
# Use uv sync for proper dependency management
# Binary files are now available, so Hatchling can include them
uv sync --dev
uv pip install -e .
- name: Validate dynamic version generation
shell: bash
run: |
# Test that hatch-vcs can generate version from git tag
echo "Testing dynamic version generation..."
# Use git describe to get version (no need for hatchling at runtime)
GENERATED_VERSION=$(git describe --tags --match='v*' | sed 's/^v//')
echo "Generated version: $GENERATED_VERSION"
# Skip validation if version generation failed
if [ "$GENERATED_VERSION" = "0.0.0" ]; then
echo "⚠ Version generation failed, skipping validation"
exit 0
fi
# Verify version format is PEP 440 compliant
if [[ $GENERATED_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(a[0-9]+|b[0-9]+|rc[0-9]+)?$ ]]; then
echo "✓ Generated version is PEP 440 compliant: $GENERATED_VERSION"
else
echo "✗ Generated version is not PEP 440 compliant: $GENERATED_VERSION"
exit 1
fi
# Verify version matches git tag (remove 'v' prefix from tag)
GIT_TAG="${{ steps.validate_tag.outputs.git_tag }}"
EXPECTED_VERSION=$(echo "$GIT_TAG" | sed 's/^v//')
if [ "$GENERATED_VERSION" = "$EXPECTED_VERSION" ]; then
echo "✓ Generated version matches git tag: $GENERATED_VERSION"
else
echo "✗ Version mismatch - Git tag: $GIT_TAG, Generated: $GENERATED_VERSION"
exit 1
fi
- name: Build wheel
shell: bash
run: |
echo "Building wheel for ${{ matrix.platform }}-${{ matrix.arch }}..."
# Build platform-specific wheel
uv build --wheel
# Rename wheel to include correct platform tag
WHEEL_FILE=$(ls dist/*.whl)
WHEEL_TAG="${{ matrix.wheel_tag }}"
NEW_NAME=$(echo "$WHEEL_FILE" | sed "s/py3-none-any/py3-none-${WHEEL_TAG}/")
echo "Renaming wheel: $WHEEL_FILE -> $NEW_NAME"
mv "$WHEEL_FILE" "$NEW_NAME"
- name: Check wheel for duplicate filenames
shell: bash
run: |
echo "Checking wheel for duplicate filenames..."
# Use our duplicate checker script
uv run python scripts/check_wheel_dups.py --verbose dist/*.whl
- name: Run twine check
shell: bash
run: |
echo "Running twine check on wheel..."
uv run pip install twine
uv run twine check dist/*.whl
- name: Verify wheel version consistency
shell: bash
run: |
# Extract version from built wheel filename
WHEEL_FILE=$(ls dist/*.whl | head -1)
WHEEL_VERSION=$(echo "$WHEEL_FILE" | sed -n 's/.*py7zz-\([^-]*\)-.*/\1/p')
echo "Wheel version: $WHEEL_VERSION"
# Compare with expected version from git tag
GIT_TAG="${{ steps.validate_tag.outputs.git_tag }}"
EXPECTED_VERSION=$(echo "$GIT_TAG" | sed 's/^v//')
if [ "$WHEEL_VERSION" = "$EXPECTED_VERSION" ]; then
echo "✓ Wheel version matches git tag: $WHEEL_VERSION"
else
echo "✗ Wheel version mismatch - Expected: $EXPECTED_VERSION, Got: $WHEEL_VERSION"
exit 1
fi
# Test install and version check
echo "Testing wheel installation and version verification..."
uv pip install dist/*.whl --force-reinstall
INSTALLED_VERSION=$(uv run python -c "import py7zz; print(py7zz.get_version())")
echo "Installed version: $INSTALLED_VERSION"
if [ "$INSTALLED_VERSION" = "$EXPECTED_VERSION" ]; then
echo "✓ Installed version matches git tag: $INSTALLED_VERSION"
else
echo "✗ Installed version mismatch - Expected: $EXPECTED_VERSION, Got: $INSTALLED_VERSION"
exit 1
fi
- name: Upload wheel artifacts
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.platform }}-${{ matrix.arch }}
path: dist/*.whl
publish-pypi:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
environment: pypi
permissions:
contents: read
id-token: write
name: Publish to PyPI
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
pattern: wheels-*
merge-multiple: true
- name: Final version validation before PyPI publish
shell: bash
run: |
# Get git tag and expected version
GIT_TAG=$(echo ${{ github.ref }} | sed 's/refs\/tags\///')
EXPECTED_VERSION=$(echo "$GIT_TAG" | sed 's/^v//')
echo "Git tag: $GIT_TAG"
echo "Expected PyPI version: $EXPECTED_VERSION"
# Verify all wheels have consistent version
echo "Verifying wheel versions..."
for wheel in dist/*.whl; do
WHEEL_VERSION=$(echo "$wheel" | sed -n 's/.*py7zz-\([^-]*\)-.*/\1/p')
echo " $wheel -> $WHEEL_VERSION"
if [ "$WHEEL_VERSION" != "$EXPECTED_VERSION" ]; then
echo "✗ Wheel version mismatch: $wheel"
echo " Expected: $EXPECTED_VERSION"
echo " Got: $WHEEL_VERSION"
exit 1
fi
done
echo "✓ All wheels have consistent version: $EXPECTED_VERSION"
# Final confirmation
echo "🚀 Ready to publish to PyPI:"
echo " Git tag: $GIT_TAG"
echo " PyPI version: $EXPECTED_VERSION"
echo " Wheel count: $(ls dist/*.whl | wc -l)"
ls -la dist/
- name: Final duplicate filename check before upload
shell: bash
run: |
echo "Running final duplicate filename check on all wheels..."
pip install zipfile-deflate64 # For better wheel support
python -c "
import glob, zipfile, collections, sys
wheels = glob.glob('dist/*.whl')
print(f'Checking {len(wheels)} wheels for duplicates...')
errors = False
for wheel in wheels:
try:
z = zipfile.ZipFile(wheel)
files = [i.filename for i in z.infolist()]
# Check exact duplicates
cnt = collections.Counter(files)
dups = [k for k,v in cnt.items() if v>1]
# Check case-insensitive duplicates
lower = collections.Counter(f.lower() for f in files)
case_dups = [k for k,v in lower.items() if v>1]
if dups or case_dups:
print(f'ERROR {wheel}:')
if dups: print(f' Exact duplicates: {dups}')
if case_dups: print(f' Case duplicates: {case_dups}')
errors = True
else:
print(f'OK {wheel} - clean ({len(files)} files)')
except Exception as e:
print(f'ERROR checking {wheel}: {e}')
errors = True
if errors:
print('ERROR: Duplicate filename errors found - aborting upload')
sys.exit(1)
else:
print('SUCCESS: All wheels passed duplicate check!')
"
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: true
packages-dir: dist/
verbose: true
github-release:
needs: publish-pypi
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Get full history for changelog
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
pattern: wheels-*
merge-multiple: true
- name: Check 7zz version change
id: check_7zz_version
run: |
# Read current bundled 7zz version
CURRENT_7ZZ=$(cat py7zz/7zz_version.txt | tr -d '[:space:]')
echo "Current 7zz version: $CURRENT_7ZZ"
# Get last tag to compare
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "Last tag: $LAST_TAG"
if [ -n "$LAST_TAG" ]; then
# Try to read 7zz version from previous tag
PREV_7ZZ=$(git show ${LAST_TAG}:py7zz/7zz_version.txt 2>/dev/null | tr -d '[:space:]' || echo "")
echo "Previous 7zz version: $PREV_7ZZ"
if [ -n "$PREV_7ZZ" ] && [ "$CURRENT_7ZZ" != "$PREV_7ZZ" ]; then
echo "7zz version changed: $PREV_7ZZ -> $CURRENT_7ZZ"
echo "changed=true" >> $GITHUB_OUTPUT
echo "message=Update bundled 7zz from $PREV_7ZZ to $CURRENT_7ZZ" >> $GITHUB_OUTPUT
elif [ -z "$PREV_7ZZ" ]; then
# Version file didn't exist in previous tag
echo "7zz version file is new in this release"
echo "changed=true" >> $GITHUB_OUTPUT
echo "message=Bundled 7zz $CURRENT_7ZZ" >> $GITHUB_OUTPUT
else
echo "7zz version unchanged: $CURRENT_7ZZ"
echo "changed=false" >> $GITHUB_OUTPUT
fi
else
# First release ever
echo "First release, showing 7zz version"
echo "changed=true" >> $GITHUB_OUTPUT
echo "message=Bundled 7zz $CURRENT_7ZZ" >> $GITHUB_OUTPUT
fi
- name: Get Release Draft
id: get_release_draft
uses: release-drafter/release-drafter@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Only run for stable releases
if: ${{ !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
- name: Generate pre-release notes
id: pre_release_notes
if: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
run: |
# Get commits since last tag (stable or pre-release)
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
# Generate categorized release notes
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
# Get commits and categorize them
git log --format="%s" --no-merges ${LAST_TAG}..HEAD > commits.txt
# Initialize category flags
has_feat=false
has_fix=false
has_docs=false
has_ci=false
has_perf=false
has_test=false
has_refactor=false
has_style=false
has_chore=false
has_other=false
# Check which categories we have
while IFS= read -r commit; do
if [[ "$commit" =~ ^feat(\(.+\))?: ]]; then has_feat=true
elif [[ "$commit" =~ ^fix(\(.+\))?: ]]; then has_fix=true
elif [[ "$commit" =~ ^docs(\(.+\))?: ]]; then has_docs=true
elif [[ "$commit" =~ ^ci(\(.+\))?: ]]; then has_ci=true
elif [[ "$commit" =~ ^perf(\(.+\))?: ]]; then has_perf=true
elif [[ "$commit" =~ ^test(\(.+\))?: ]]; then has_test=true
elif [[ "$commit" =~ ^refactor(\(.+\))?: ]]; then has_refactor=true
elif [[ "$commit" =~ ^style(\(.+\))?: ]]; then has_style=true
elif [[ "$commit" =~ ^chore(\(.+\))?: ]]; then has_chore=true
else has_other=true
fi
done < commits.txt
# Generate sections only for existing categories
if [ "$has_feat" = true ]; then
echo "🚀 Features" >> release_notes.md
echo "" >> release_notes.md
grep -E "^feat(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_fix" = true ]; then
echo "🐛 Bug Fixes" >> release_notes.md
echo "" >> release_notes.md
grep -E "^fix(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_docs" = true ]; then
echo "📚 Documentation" >> release_notes.md
echo "" >> release_notes.md
grep -E "^docs(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_ci" = true ]; then
echo "🔄 CI/CD" >> release_notes.md
echo "" >> release_notes.md
grep -E "^ci(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_perf" = true ]; then
echo "⚡ Performance" >> release_notes.md
echo "" >> release_notes.md
grep -E "^perf(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_test" = true ]; then
echo "🧪 Testing" >> release_notes.md
echo "" >> release_notes.md
grep -E "^test(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_refactor" = true ]; then
echo "🔧 Maintenance" >> release_notes.md
echo "" >> release_notes.md
grep -E "^refactor(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_style" = true ]; then
echo "🔧 Maintenance" >> release_notes.md
echo "" >> release_notes.md
grep -E "^style(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_chore" = true ]; then
echo "🔧 Maintenance" >> release_notes.md
echo "" >> release_notes.md
grep -E "^chore(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
if [ "$has_other" = true ]; then
echo "Other Changes" >> release_notes.md
echo "" >> release_notes.md
grep -v -E "^(feat|fix|docs|ci|perf|test|refactor|style|chore)(\(.+\))?: " commits.txt | sed 's/^/- /' >> release_notes.md
echo "" >> release_notes.md
fi
# Add Build System section if 7zz version changed
if [ "${{ steps.check_7zz_version.outputs.changed }}" == "true" ]; then
echo "🏗️ Build System" >> release_notes.md
echo "" >> release_notes.md
echo "- ${{ steps.check_7zz_version.outputs.message }}" >> release_notes.md
echo "" >> release_notes.md
fi
# Add footer
echo "---" >> release_notes.md
echo "" >> release_notes.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...${{ github.ref_name }}" >> release_notes.md
# Clean up
rm commits.txt
else
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
echo "- Initial pre-release" >> release_notes.md
echo "" >> release_notes.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/commits/${{ github.ref_name }}" >> release_notes.md
fi
# Set output
echo "notes<<EOF" >> $GITHUB_OUTPUT
cat release_notes.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release (Pre-release)
uses: ncipollo/release-action@v1
if: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
with:
tag: ${{ github.ref_name }}
name: "${{ github.ref_name }}"
body: ${{ steps.pre_release_notes.outputs.notes }}
artifacts: "dist/*.whl"
draft: false
prerelease: true
generateReleaseNotes: false
allowUpdates: true
artifactErrorsFailBuild: true
makeLatest: false
- name: Prepare stable release body
id: prepare_stable_body
if: ${{ !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
env:
DRAFT_BODY: ${{ steps.get_release_draft.outputs.body }}
VERSION_CHANGED: ${{ steps.check_7zz_version.outputs.changed }}
VERSION_MESSAGE: ${{ steps.check_7zz_version.outputs.message }}
run: |
if [ "$VERSION_CHANGED" == "true" ]; then
# Insert Build System section before Contributors using awk
echo "$DRAFT_BODY" | awk -v msg="$VERSION_MESSAGE" '
/^## Contributors/ {
print "## 🏗️ Build System"
print ""
print "- " msg
print ""
}
{ print }
' > final_body.md
else
echo "$DRAFT_BODY" > final_body.md
fi
echo "body<<EOF" >> $GITHUB_OUTPUT
cat final_body.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release (Stable)
uses: ncipollo/release-action@v1
if: ${{ !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') && !contains(github.ref_name, 'rc') }}
with:
tag: ${{ github.ref_name }}
name: "${{ github.ref_name }}"
body: ${{ steps.prepare_stable_body.outputs.body }}
artifacts: "dist/*.whl"
draft: false
prerelease: false
generateReleaseNotes: false
allowUpdates: true
artifactErrorsFailBuild: true
makeLatest: true