name: Build and Publish on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Manual build version tag (e.g. v2.7.5.dev0)' required: false default: 'v2.7.5.dev0' publish_pypi: description: 'Publish to PyPI during manual test?' type: boolean required: false default: false env: PROJECT_NAME: qpypack MAIN_SCRIPT: qpypack/main.py ICON_PATH: qpypack/icon.ico EXE_NAME_PREFIX: QPyPack BUILD_PIP_PACKAGE: true PUBLISH_TO_PYPI: true COMPANY_NAME: QwejayHuang PRODUCT_NAME: QPyPack LEGAL_COPYRIGHT: Copyright (C) QwejayHuang FILE_DESCRIPTION: Automation Python Packaging and Freezing Suite jobs: publish-pypi: name: Publish to PyPI and Create Release runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - name: Checkout source code uses: actions/checkout@v4 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10' - name: Sync version from Tag to files shell: python run: | import re import os tag = "${{ inputs.version || github.ref_name }}" version = tag[1:] if tag.lower().startswith('v') else tag toml_path = 'pyproject.toml' if os.path.exists(toml_path): with open(toml_path, 'r', encoding='utf-8') as f: content = f.read() new_content = re.sub(r'(version\s*=\s*["\'])[^"\']+(["\'])', f'\\g<1>{version}\\g<2>', content, count=1) with open(toml_path, 'w', encoding='utf-8') as f: f.write(new_content) main_path = os.environ['MAIN_SCRIPT'] if os.path.exists(main_path): with open(main_path, 'r', encoding='utf-8') as f: content = f.read() new_content = re.sub(r'(__version__\s*=\s*["\'])[^"\']+(["\'])', f'\\g<1>{version}\\g<2>', content, count=1) with open(main_path, 'w', encoding='utf-8') as f: f.write(new_content) - name: Install build dependencies if: env.BUILD_PIP_PACKAGE == 'true' run: | python -m pip install --upgrade pip python -m pip install build - name: Build sdist and wheel if: env.BUILD_PIP_PACKAGE == 'true' run: python -m build - name: Create GitHub Release if: env.BUILD_PIP_PACKAGE == 'true' uses: softprops/action-gh-release@v2 with: files: | dist/*.whl dist/*.tar.gz generate_release_notes: true tag_name: ${{ inputs.version || github.ref_name }} prerelease: ${{ contains(inputs.version || github.ref_name, 'dev') || contains(inputs.version || github.ref_name, 'rc') || contains(inputs.version || github.ref_name, 'alpha') || contains(inputs.version || github.ref_name, 'beta') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish to PyPI if: env.BUILD_PIP_PACKAGE == 'true' && env.PUBLISH_TO_PYPI == 'true' && (github.event_name == 'push' || inputs.publish_pypi == true) uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true build-binaries: name: Build Standalone for ${{ matrix.os }} (${{ matrix.arch }}) needs: publish-pypi runs-on: ${{ matrix.os }} permissions: contents: write strategy: fail-fast: false matrix: include: - os: windows-latest arch: x64 - os: macos-14 arch: arm64 - os: ubuntu-22.04 arch: x64 steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'pip' - name: Extract version shell: bash run: | TAG_NAME="${{ inputs.version || github.ref_name }}" VERSION=${TAG_NAME#v} echo "VERSION=$VERSION" >> $GITHUB_ENV - name: Sync version to main.py shell: python run: | import re import os version = os.environ['VERSION'] main_path = os.environ['MAIN_SCRIPT'] with open(main_path, 'r', encoding='utf-8') as f: content = f.read() new_content = re.sub(r'(__version__\s*=\s*["\'])[^"\']+(["\'])', f'\\g<1>{version}\\g<2>', content, count=1) with open(main_path, 'w', encoding='utf-8') as f: f.write(new_content) - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install PySide6 Pillow pyinstaller - name: Convert ICO to ICNS (macOS Only) if: runner.os == 'macOS' shell: python run: | import os from PIL import Image img = Image.open(os.environ['ICON_PATH']) icns_path = 'qpypack/icon.icns' img.save(icns_path, format='ICNS') with open(os.environ['GITHUB_ENV'], 'a') as f: f.write(f"MAC_ICON_PATH={icns_path}\n") - name: Generate Windows Version Info File if: runner.os == 'Windows' shell: python run: | import os, re version = os.environ['VERSION'] parts = re.findall(r'\d+', version) v_tuple = ",".join((parts + ['0', '0', '0', '0'])[:4]) info_content = f""" VSVersionInfo( ffi=FixedFileInfo(filevers=({v_tuple}),prodvers=({v_tuple}),mask=0x3f,flags=0x0,OS=0x40004,fileType=0x1,subtype=0x0,date=(0,0)), kids=[ StringFileInfo([StringTable('040904B0',[StringStruct('CompanyName', '${{ env.COMPANY_NAME }}'),StringStruct('FileDescription', '${{ env.FILE_DESCRIPTION }}'),StringStruct('FileVersion', '{version}'),StringStruct('InternalName', '${{ env.EXE_NAME_PREFIX }}'),StringStruct('LegalCopyright', '${{ env.LEGAL_COPYRIGHT }}'),StringStruct('OriginalFilename', '${{ env.EXE_NAME_PREFIX }}.exe'),StringStruct('ProductName', '${{ env.PRODUCT_NAME }}'),StringStruct('ProductVersion', '{version}')])]), VarFileInfo([VarStruct('Translation', [1033, 1200])]) ] ) """ with open('file_version_info.txt', 'w', encoding='utf-8') as f: f.write(info_content.strip()) - name: Build with PyInstaller (All Platforms) shell: bash run: | if [ "${{ runner.os }}" = "Windows" ]; then pyinstaller --clean --onefile --noconsole --version-file=file_version_info.txt --icon=${{ env.ICON_PATH }} --add-data "${{ env.ICON_PATH }};." --add-data "qpypack/locales;locales" --name="${{ env.EXE_NAME_PREFIX }}_Standalone" ${{ env.MAIN_SCRIPT }} pyinstaller --clean --onedir --noconsole --version-file=file_version_info.txt --icon=${{ env.ICON_PATH }} --add-data "${{ env.ICON_PATH }};." --add-data "qpypack/locales;locales" --name="${{ env.EXE_NAME_PREFIX }}_Portable" ${{ env.MAIN_SCRIPT }} elif [ "${{ runner.os }}" = "macOS" ]; then pyinstaller --clean --onedir --noconsole --name="${{ env.EXE_NAME_PREFIX }}" --icon=${{ env.MAC_ICON_PATH }} --add-data "${{ env.ICON_PATH }}:." --add-data "qpypack/locales:locales" ${{ env.MAIN_SCRIPT }} codesign --force --deep --sign - dist/${{ env.EXE_NAME_PREFIX }}.app else PYSIDE_PLUGINS=$(python -c "import PySide6, os; print(os.path.join(os.path.dirname(PySide6.__file__), 'Qt', 'plugins'))") mkdir -p qt_plugins cp -r "$PYSIDE_PLUGINS/platforms" qt_plugins/ cp -r "$PYSIDE_PLUGINS/wayland"* qt_plugins/ 2>/dev/null || true pyinstaller --clean --noupx --onefile --noconsole --name="${{ env.EXE_NAME_PREFIX }}_Standalone" --icon=${{ env.ICON_PATH }} --add-data "${{ env.ICON_PATH }}:." --add-data "qt_plugins:PySide6/Qt/plugins" --add-data "qpypack/locales:locales" ${{ env.MAIN_SCRIPT }} pyinstaller --clean --noupx --onedir --noconsole --name="${{ env.EXE_NAME_PREFIX }}_Portable" --icon=${{ env.ICON_PATH }} --add-data "${{ env.ICON_PATH }}:." --add-data "qt_plugins:PySide6/Qt/plugins" --add-data "qpypack/locales:locales" ${{ env.MAIN_SCRIPT }} fi - name: Package Artifact (Windows) if: runner.os == 'Windows' shell: pwsh run: | $BASE_NAME = "${{ env.EXE_NAME_PREFIX }}-${{ env.VERSION }}-windows-${{ matrix.arch }}" Compress-Archive -Path "dist/${{ env.EXE_NAME_PREFIX }}_Standalone.exe" -DestinationPath "$BASE_NAME-Standalone.zip" -Force Compress-Archive -Path "dist/${{ env.EXE_NAME_PREFIX }}_Portable" -DestinationPath "$BASE_NAME-Portable.zip" -Force - name: Package Artifact (macOS / Linux) if: runner.os != 'Windows' shell: bash run: | OS_NAME=$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]') cd dist if [ "${{ runner.os }}" = "macOS" ]; then BASE_NAME="${{ env.EXE_NAME_PREFIX }}-${{ env.VERSION }}-${OS_NAME}-${{ matrix.arch }}" mkdir -p dmg_staging mv "${{ env.EXE_NAME_PREFIX }}.app" dmg_staging/ ln -s /Applications dmg_staging/Applications hdiutil create -volname "${{ env.EXE_NAME_PREFIX }}" -srcfolder dmg_staging -ov -format UDZO "../${BASE_NAME}.dmg" else BASE_NAME="${{ env.EXE_NAME_PREFIX }}-${{ env.VERSION }}-${OS_NAME}-${{ matrix.arch }}" tar -czf "../${BASE_NAME}-Standalone.tar.gz" "${{ env.EXE_NAME_PREFIX }}_Standalone" tar -czf "../${BASE_NAME}-Portable.tar.gz" "${{ env.EXE_NAME_PREFIX }}_Portable" fi - name: Append Standalone Binary to GitHub Release uses: softprops/action-gh-release@v2 with: files: ${{ env.EXE_NAME_PREFIX }}-*.* tag_name: ${{ inputs.version || github.ref_name }} prerelease: ${{ contains(inputs.version || github.ref_name, 'dev') || contains(inputs.version || github.ref_name, 'rc') || contains(inputs.version || github.ref_name, 'alpha') || contains(inputs.version || github.ref_name, 'beta') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}