如果你平时在 macOS 上写 Python 脚本、桌面小工具或命令行工具,最后却要发给 Windows 用户使用,最容易遇到的问题就是:我能不能直接在 Mac 上打包出 .exe

结论先说清楚:不建议在 macOS 本机强行交叉打包 Windows EXE。更稳的办法是把代码推到 GitHub,让 GitHub Actions 在 Windows 环境里自动打包。

这篇文章按使用教程来写,从准备项目、创建 workflow、触发构建、下载产物,到常见问题排查,完整走一遍。

总览:为什么要用 GitHub Actions 打包 EXE#

PyInstaller 这类打包工具通常不是“万能交叉编译器”。也就是说:

  • 要打 Windows .exe,最好在 Windows 上运行打包命令。
  • 要打 macOS 应用,就在 macOS 上打。
  • 要打 Linux 可执行文件,就在 Linux 上打。

这不是 GitHub Actions 的特殊限制,而是很多 Python 原生依赖、动态库、运行时文件和系统 API 都跟目标操作系统绑定。你在 macOS 上直接打包,得到的通常是 macOS 可执行文件,而不是 Windows 真正可用的 .exe

GitHub Actions 的思路是:

macOS 本地开发
  -> git push 到 GitHub
  -> GitHub Actions 启动 Windows Runner
  -> 安装 Python 和依赖
  -> 执行 PyInstaller
  -> 上传 exe 产物
  -> 在 Actions 页面下载

这样做的好处是:

  • 不需要自己安装 Windows 虚拟机。
  • 每次构建环境更干净,问题更容易复现。
  • 可以把打包流程固化成配置文件,后面一键复用。
  • 可以同时扩展出 Windows、macOS、Linux 多平台产物。

准备一个最小 Python 项目#

假设你的项目结构是这样:

my-tool/
├── main.py
├── requirements.txt
└── README.md

main.py 示例:

import sys


def main():
    print("Hello from packaged exe")
    print(f"Python: {sys.version}")


if __name__ == "__main__":
    main()

requirements.txt 至少要写上 PyInstaller:

pyinstaller

如果你的项目还用到了其他库,比如 requestspillowpyqt6,也写进去:

pyinstaller
requests
pillow

本地可以先确认脚本能正常运行:

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python main.py

本地能跑通之后,再交给 GitHub Actions 打包。

第一步:创建 workflow 文件#

在项目根目录创建目录:

mkdir -p .github/workflows

然后创建文件:

.github/workflows/build-windows-exe.yml

写入下面这份配置:

name: Build Windows EXE

on:
  workflow_dispatch:
  push:
    branches:
      - main

jobs:
  build-windows:
    runs-on: windows-latest

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Build exe with PyInstaller
        run: |
          pyinstaller --onefile --name my-tool main.py

      - name: Upload exe artifact
        uses: actions/upload-artifact@v4
        with:
          name: my-tool-windows-exe
          path: dist/my-tool.exe

这份 workflow 做了几件事:

  • runs-on: windows-latest:让任务运行在 GitHub 提供的 Windows 环境里。
  • actions/checkout@v4:拉取你的仓库代码。
  • actions/setup-python@v5:安装指定版本的 Python。
  • pip install -r requirements.txt:安装项目依赖。
  • pyinstaller --onefile --name my-tool main.py:打包成单文件 EXE。
  • actions/upload-artifact@v4:把 dist/my-tool.exe 上传成可下载产物。

第二步:提交到 GitHub#

把 workflow 文件提交上去:

git add .github/workflows/build-windows-exe.yml requirements.txt main.py
git commit -m "Add Windows exe build workflow"
git push origin main

推送后,GitHub 会自动执行一次构建。你也可以手动触发,因为上面的配置里写了:

workflow_dispatch:

手动触发路径是:

GitHub 仓库页面
  -> Actions
  -> Build Windows EXE
  -> Run workflow

第三步:下载打包好的 EXE#

构建完成后:

GitHub 仓库页面
  -> Actions
  -> 选择最新一次运行记录
  -> 页面底部 Artifacts
  -> 下载 my-tool-windows-exe

下载下来通常是一个压缩包,解压后可以看到:

my-tool.exe

把这个文件发给 Windows 用户测试即可。

常用 PyInstaller 参数#

最常见的是单文件命令:

pyinstaller --onefile --name my-tool main.py

如果你是 GUI 程序,不想弹出黑色命令行窗口,可以加:

pyinstaller --onefile --windowed --name my-tool main.py

如果程序需要图标:

pyinstaller --onefile --name my-tool --icon assets/app.ico main.py

如果需要带上资源文件,比如配置模板、图片、模型文件,可以使用 --add-data。在 Windows 上分隔符是分号:

pyinstaller --onefile --name my-tool --add-data "assets;assets" main.py

对应到 workflow:

- name: Build exe with PyInstaller
  run: |
    pyinstaller --onefile --name my-tool --add-data "assets;assets" main.py

如果资源较多,建议先用 .spec 文件管理配置,而不是把所有参数堆在命令行里。

使用 spec 文件管理复杂打包#

先在本地或 CI 里生成 spec 文件:

pyinstaller --onefile --name my-tool main.py

生成后会出现:

my-tool.spec

把这个文件提交到仓库,然后 workflow 改成:

- name: Build exe with PyInstaller spec
  run: |
    pyinstaller my-tool.spec

适合使用 .spec 的情况:

  • 需要打包多个入口文件。
  • 需要加入大量资源目录。
  • 需要处理 hidden imports。
  • GUI 框架、OCR、机器学习模型等依赖比较复杂。

只在发布 tag 时打包#

如果你不想每次 push 都构建 EXE,可以改成只在打 tag 时构建:

name: Build Windows EXE

on:
  workflow_dispatch:
  push:
    tags:
      - "v*"

jobs:
  build-windows:
    runs-on: windows-latest

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Build exe with PyInstaller
        run: |
          pyinstaller --onefile --name my-tool main.py

      - name: Upload exe artifact
        uses: actions/upload-artifact@v4
        with:
          name: my-tool-windows-exe
          path: dist/my-tool.exe

发版时执行:

git tag v1.0.0
git push origin v1.0.0

这样只有 v1.0.0v1.1.0 这种 tag 会触发构建。

自动发布到 GitHub Release#

如果你希望打包完成后自动挂到 GitHub Release,可以加上 release 步骤。

完整示例:

name: Release Windows EXE

on:
  workflow_dispatch:
  push:
    tags:
      - "v*"

permissions:
  contents: write

jobs:
  release-windows:
    runs-on: windows-latest

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Build exe with PyInstaller
        run: |
          pyinstaller --onefile --name my-tool main.py

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: dist/my-tool.exe

这里要注意:

  • permissions: contents: write 是为了让 workflow 有权限创建 Release。
  • softprops/action-gh-release@v2 会把 dist/my-tool.exe 上传到当前 tag 对应的 Release。
  • 这个流程适合正式版本,不一定适合每次普通提交。

多平台一起打包#

如果你还想顺便生成 macOS 和 Linux 产物,可以用矩阵构建:

name: Build Multi Platform

on:
  workflow_dispatch:

jobs:
  build:
    strategy:
      matrix:
        include:
          - os: windows-latest
            artifact_name: my-tool-windows
            output_path: dist/my-tool.exe
          - os: macos-latest
            artifact_name: my-tool-macos
            output_path: dist/my-tool
          - os: ubuntu-latest
            artifact_name: my-tool-linux
            output_path: dist/my-tool

    runs-on: ${{ matrix.os }}

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Build executable
        run: |
          pyinstaller --onefile --name my-tool main.py

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.artifact_name }}
          path: ${{ matrix.output_path }}

这个例子里,每个平台都在自己的系统环境中构建自己的可执行文件。不要指望 macOS 那一份产物能变成 Windows .exe,Windows .exewindows-latest 那个任务产出的。

常见问题 / 排查思路#

1. Actions 里提示找不到 requirements.txt#

先确认文件确实在仓库根目录:

my-tool/
├── requirements.txt
└── .github/workflows/build-windows-exe.yml

如果你的 Python 项目在子目录,比如:

my-tool/
└── app/
    ├── main.py
    └── requirements.txt

那 workflow 要加工作目录:

- name: Install dependencies
  working-directory: app
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt

- name: Build exe with PyInstaller
  working-directory: app
  run: |
    pyinstaller --onefile --name my-tool main.py

2. EXE 打开后闪退#

命令行程序双击后窗口可能一闪而过。先在 Windows 的 PowerShell 里运行:

.\my-tool.exe

这样可以看到具体报错。

如果是缺少模块,常见处理方式是加 hidden import:

pyinstaller --onefile --name my-tool --hidden-import some_module main.py

复杂项目建议改 .spec 文件。

3. 资源文件找不到#

打包后当前目录不一定等于源码目录。读取资源时不要只依赖相对路径,可以封装一个函数:

from pathlib import Path
import sys


def resource_path(relative_path: str) -> Path:
    if hasattr(sys, "_MEIPASS"):
        return Path(sys._MEIPASS) / relative_path
    return Path(__file__).resolve().parent / relative_path

使用时:

config_path = resource_path("assets/config.json")

打包时带上资源:

pyinstaller --onefile --name my-tool --add-data "assets;assets" main.py

4. 杀毒软件误报#

单文件 EXE 被误报并不少见,尤其是小工具、无签名、压缩过的可执行文件。可以尝试:

  • 不使用 UPX 压缩。
  • 改用 --onedir 方式发布。
  • 给正式分发版本做代码签名。
  • 在 Release 里附上源码、版本说明和校验值。

--onedir 示例:

pyinstaller --onedir --name my-tool main.py

这会生成一个目录,而不是单个 EXE。体积和文件数量会增加,但有时兼容性更好。

5. 中文路径或空格路径导致问题#

CI 环境里一般不会遇到本地用户目录那种中文路径问题,但资源文件名仍建议使用英文、小写、中横线,例如:

assets/default-config.json
assets/app-icon.ico

这样可以减少 Windows 编码、路径转义、命令行参数解析带来的麻烦。

推荐的最终 workflow#

实际项目里,我更推荐从这个版本开始:

name: Build Windows EXE

on:
  workflow_dispatch:
  push:
    tags:
      - "v*"

jobs:
  build-windows:
    runs-on: windows-latest

    steps:
      - name: Checkout source code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Build exe
        run: |
          pyinstaller --onefile --name my-tool main.py

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: my-tool-windows-exe
          path: dist/my-tool.exe

它的特点是:

  • 平时普通提交不会频繁打包。
  • 需要发版本时打一个 v* tag 即可。
  • 也可以在 Actions 页面手动运行。
  • 产物通过 artifact 下载,简单直接。

参考资料#

  • PyInstaller 官方文档说明:PyInstaller 不是通用交叉编译器,通常应在目标系统上构建目标系统的可执行文件。
  • GitHub Actions 官方文档说明:GitHub-hosted runners 提供 Windows、macOS、Ubuntu 等运行环境,windows-latest 这类标签会指向 GitHub 当前维护的稳定镜像。
  • actions/setup-python 官方说明:在 workflow 中推荐使用它安装和缓存 Python 环境。

总结#

在 macOS 上想给 Windows 用户打包 .exe,最稳定的办法不是在 Mac 上硬凑交叉编译环境,而是把“打包动作”放到 Windows 环境中执行。

GitHub Actions 正好提供了这个能力:你继续在 macOS 上开发,提交代码后让 windows-latest runner 自动安装依赖、运行 PyInstaller、上传 EXE。这样流程清晰、环境可复现,也方便以后扩展成自动发布 Release。