diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0989197..ed80986 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,174 +1,154 @@ -name: Build and Release +name: Test, Build and Release on: push: + branches: + - main tags: - 'v*' + pull_request: workflow_dispatch: inputs: version: - description: 'Version tag (e.g., v1.0.0)' + description: 'Release tag, for example v1.0.1' required: true - default: 'v1.0.0' + type: string + +permissions: + contents: read jobs: - build-windows: + test: + name: Windows unit tests runs-on: windows-latest - + timeout-minutes: 15 + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build Windows executable - run: | - pyinstaller --onefile --name "AutoClickerPro" --icon=NONE --add-data "config;config" --add-data "core;core" --add-data "ui;ui" --add-data "utils;utils" main.py - shell: pwsh - - - name: Create Windows release package - run: | - New-Item -ItemType Directory -Force -Path release - Copy-Item dist\AutoClickerPro.exe release\ - Copy-Item README.md release\ - Copy-Item LICENSE release\ 2>$null || echo "No LICENSE file" - Compress-Archive -Path release\* -DestinationPath AutoClickerPro_Windows.zip -Force - shell: pwsh - - - name: Upload Windows artifact - uses: actions/upload-artifact@v4 - with: - name: windows-build - path: AutoClickerPro_Windows.zip - - build-linux: - runs-on: ubuntu-latest - + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run unit tests + run: python -m unittest discover tests -v + + build-windows: + name: Build Windows executable + needs: test + runs-on: windows-latest + timeout-minutes: 20 + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build Linux executable - run: | - pyinstaller --onefile --name "AutoClickerPro" --icon=NONE --add-data "config:config" --add-data "core:core" --add-data "ui:ui" --add-data "utils:utils" main.py - - - name: Create Linux release package - run: | - mkdir -p release - cp dist/AutoClickerPro release/ - cp README.md release/ - cp LICENSE release/ 2>/dev/null || echo "No LICENSE file" - cd release && tar -czvf ../AutoClickerPro_Linux.tar.gz * - - - name: Upload Linux artifact - uses: actions/upload-artifact@v4 - with: - name: linux-build - path: AutoClickerPro_Linux.tar.gz - - create-release: - needs: [build-windows, build-linux] + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pyinstaller + + - name: Build through the canonical packaging script + run: python pack.py --ci + + - name: Validate executable + shell: pwsh + run: | + if (-not (Test-Path 'dist\AutoClickerPro.exe')) { + throw 'dist\AutoClickerPro.exe was not created' + } + $size = (Get-Item 'dist\AutoClickerPro.exe').Length + if ($size -le 0) { + throw 'AutoClickerPro.exe is empty' + } + Write-Host "Executable size: $size bytes" + + - name: Create release package + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release | Out-Null + Copy-Item 'dist\AutoClickerPro.exe' 'release\AutoClickerPro.exe' + Copy-Item 'README.md' 'release\README.md' + if (Test-Path 'LICENSE') { + Copy-Item 'LICENSE' 'release\LICENSE' + } + Compress-Archive -Path 'release\*' -DestinationPath 'AutoClickerPro_Windows.zip' -Force + if ((Get-Item 'AutoClickerPro_Windows.zip').Length -le 0) { + throw 'Release ZIP is empty' + } + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: AutoClickerPro_Windows.zip + if-no-files-found: error + retention-days: 14 + + release: + name: Publish GitHub Release + needs: build-windows + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - + permissions: + contents: write + steps: - - name: Download Windows artifact - uses: actions/download-artifact@v4 - with: - name: windows-build - path: ./artifacts - - - name: Download Linux artifact - uses: actions/download-artifact@v4 - with: - name: linux-build - path: ./artifacts - - - name: Get version from tag or input - id: get_version - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - VERSION="${{ github.event.inputs.version }}" - else - VERSION="${{ github.ref_name }}" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Generate changelog - id: changelog - run: | - cat > changelog.md << 'EOF' -## 🎉 自动点击器专业版 ${{ steps.get_version.outputs.version }} - -### ✨ 新特性 -- 🔐 **加密存储**: 宏录制文件采用 Fernet 对称加密,安全保护您的操作序列 -- 🧩 **模块化架构**: UI 与业务逻辑完全解耦,代码更易维护 -- ⚡ **高性能**: 优化的线程管理,低延迟响应 -- 🛡️ **安全防护**: 完整的输入验证和防滥用机制 -- 🎨 **现代化界面**: 简洁直观的用户体验 - -### 🔧 技术改进 -- 分离配置文件 (constants/encryption/validation) -- 新增宏管理模块 (macros.py) -- 快捷键管理器 (hotkey_manager.py) -- 完整的单元测试覆盖 (36+ 测试用例) -- CI/CD 自动化构建 - -### 📦 安装说明 -#### Windows -1. 下载 `AutoClickerPro_Windows.zip` -2. 解压到任意目录 -3. 双击运行 `AutoClickerPro.exe` - -#### Linux -1. 下载 `AutoClickerPro_Linux.tar.gz` -2. 解压:`tar -xzvf AutoClickerPro_Linux.tar.gz` -3. 运行:`./AutoClickerPro` - -### ⌨️ 快捷键 -- `F8`: 开始/停止连点 -- `F9`: 开始/停止录制 -- `F10`: 播放宏 -- `ESC`: 停止当前操作 - -### 📝 更新日志 -- 首次正式发布 -- 支持鼠标连点和键盘宏录制 -- 加密存储保护隐私 -- 跨平台支持 (Windows/Linux) -EOF - cat changelog.md - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - name: 自动点击器专业版 ${{ steps.get_version.outputs.version }} - body_path: changelog.md - files: | - ./artifacts/AutoClickerPro_Windows.zip - ./artifacts/AutoClickerPro_Linux.tar.gz - draft: false - prerelease: false - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Download tested Windows artifact + uses: actions/download-artifact@v4 + with: + name: windows-build + path: artifacts + + - name: Resolve and validate version + id: version + shell: bash + env: + DISPATCH_VERSION: ${{ inputs.version }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + VERSION="${DISPATCH_VERSION}" + else + VERSION="${GITHUB_REF_NAME}" + fi + + if [[ ! "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid version: ${VERSION}. Expected a tag such as v1.0.1." >&2 + exit 1 + fi + + test -s artifacts/AutoClickerPro_Windows.zip + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.version }} + target_commitish: ${{ github.sha }} + name: AutoClicker Pro ${{ steps.version.outputs.version }} + body: | + Automated Windows release built from the canonical `pack.py` entrypoint. + + The unit-test job completed successfully before packaging. Download `AutoClickerPro_Windows.zip`, extract it, and run `AutoClickerPro.exe`. + files: artifacts/AutoClickerPro_Windows.zip + fail_on_unmatched_files: true + generate_release_notes: true + draft: false + prerelease: false diff --git a/pack.py b/pack.py index df5ff24..efc42da 100644 --- a/pack.py +++ b/pack.py @@ -1,79 +1,91 @@ +import argparse import os -import sys import subprocess +import sys +from pathlib import Path -# ================= 配置区域 ================= -# 这里填你的源文件名 (必须带 .py) SOURCE_FILE = "main.py" -# 这里填你想生成的 exe 名字 EXE_NAME = "AutoClickerPro" -# =========================================== -def install_deps(): - """检查并自动安装必要的打包库""" - print("正在检查环境...") - required = ["pyinstaller", "customtkinter", "pynput"] - for package in required: +def install_deps() -> None: + """Install local packaging dependencies for an interactive developer build.""" + required = { + "PyInstaller": "pyinstaller", + "customtkinter": "customtkinter", + "pynput": "pynput", + "cryptography": "cryptography", + } + for module_name, package_name in required.items(): try: - __import__(package) + __import__(module_name) except ImportError: - print(f"正在安装缺失的库: {package}...") - subprocess.check_call([sys.executable, "-m", "pip", "install", package]) + print(f"Installing missing package: {package_name}") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", package_name] + ) -def build(): - # 0. 检查源文件 - if not os.path.exists(SOURCE_FILE): - print(f"\n❌ 错误:找不到文件 [{SOURCE_FILE}]") - print( - "请确认:\n1. 你的源文件名真的是 'main.py' 吗?\n2. 此脚本是否和源文件在同一个文件夹里?" +def build() -> Path: + """Build the same single-file, windowed executable locally and in CI.""" + source = Path(SOURCE_FILE) + if not source.is_file(): + raise FileNotFoundError( + f"Cannot find {SOURCE_FILE}. Run this script from the repository root." ) - return import PyInstaller.__main__ - import customtkinter - - # 1. 获取 CustomTkinter 的资源路径 (关键步骤) - ctk_path = os.path.dirname(customtkinter.__file__) - print(f"✅ 找到 UI 库路径: {ctk_path}") - - # 2. 组装 PyInstaller 参数 - # 注意:Windows下资源分隔符是分号 ; - add_data_arg = f"{ctk_path};customtkinter" args = [ - SOURCE_FILE, # 源文件 - f"--name={EXE_NAME}", # exe名字 - "--noconfirm", # 覆盖输出不询问 - "--onefile", # 打包成单文件 exe - "--windowed", # 【重要】不显示黑色的控制台窗口 - "--clean", # 清理临时缓存 - f"--add-data={add_data_arg}", # 注入 customtkinter 资源 + str(source), + f"--name={EXE_NAME}", + "--noconfirm", + "--onefile", + "--windowed", + "--clean", + "--collect-all=customtkinter", ] - # 如果你有图标,把图标放在同级目录叫 icon.ico,然后取消下面这行的注释 - # if os.path.exists("icon.ico"): args.append('--icon=icon.ico') + icon = Path("icon.ico") + if icon.is_file(): + args.append(f"--icon={icon}") - print(f"\n🚀 开始打包 [{SOURCE_FILE}] ...") - print("这可能需要 1-3 分钟,请耐心等待...\n") + print(f"Building {EXE_NAME} from {SOURCE_FILE}...") + PyInstaller.__main__.run(args) - # 3. 执行打包 - try: - PyInstaller.__main__.run(args) - print("\n" + "=" * 40) - print(f"🎉 打包成功!") - print(f"请在生成的 [dist] 文件夹里找:{EXE_NAME}.exe") - print("=" * 40) - except Exception as e: - print(f"\n❌ 打包出错: {e}") + suffix = ".exe" if os.name == "nt" else "" + artifact = Path("dist") / f"{EXE_NAME}{suffix}" + if not artifact.is_file() or artifact.stat().st_size == 0: + raise RuntimeError(f"Build completed without a valid artifact: {artifact}") + print(f"Build artifact: {artifact} ({artifact.stat().st_size} bytes)") + return artifact -if __name__ == "__main__": + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build AutoClickerPro with PyInstaller") + parser.add_argument( + "--ci", + action="store_true", + help="Do not install packages or wait for interactive input.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() try: - install_deps() + if not args.ci: + install_deps() build() - except Exception as e: - print(f"发生未知错误: {e}") + return 0 + except Exception as exc: + print(f"Build failed: {exc}", file=sys.stderr) + return 1 - input("\n按回车键退出...") + +if __name__ == "__main__": + exit_code = main() + if "--ci" not in sys.argv: + input("Press Enter to exit...") + raise SystemExit(exit_code)