Índice:
- Sección A. Estructurar un proyecto: apartado I, apartado II, apartado III, apartado IV, apartado V, apartado VI, apartado VII, apartado VIII.
Para crear la aplicación .exe
para Windows, vamos a necesitar lanzar PyInstaller a través de GitHub Actions.
¿Porqué GitHub? GitHub nos permite lanzar gratuitamente hasta 2.000 minutos de cómputo, incluyendo también una FreeTier de Windows.
¿Porqué no se hizo todo con GitHub? La mejor forma de aprender cómo integrar diferentes plataforma es haciéndolo, por lo que aprovechamos que ambas plataformas tienen planes gratuitos para mostrar como integrarlas.
Preparar el repositorio de GitHub
Vamos a GitHub y creamos un nuevo repositorio para lanzar el pipeline.
Una vez creado el repositorio, en el icono de usuario (arriba a la derecha) pulsa Settings
en el desplegable.
Nos aparecerá a la izquierda, al final de todos los settings (abajo), el apartado Developer setting
, tras abrir Personal access tokens
pulsamos sobre Tokens (clasic)
y nos creamos un nuevo token haciendo click en:
-
Generate new token
>Generate new token (classic)
, y rellenamos:- Note: ponemos un alias identificativo.
- Expiration: seleccionamos cuanto tiempo queremos que dure.
- Select scopes, seleccionamos los siguientes:
- repo (todos)
- workflow
- Pulsamos en
Generate token
.
Atención: Nos mostrará únicamente una vez el token, nunca volverás a verlo, guárdalo en tu aplicación de almacenar contraseñas.
Gitlab
Ahora en Gitlab necesitaremos introducir los datos de nuestro repo de GitHub, pero primero nos preparamos los archivos. Para ello creamos el archivo para GitHub, añadiendo a nuestro repo los siguientes archivos:
- Actualizamos
requirements-extra-windows.txt
añadiendo:
PyInstaller
- Creamos el 'github-build' que necesitaremos para GitHub:
# ci/github-build.yml
name: Build EXE
on:
push:
branches:
- __BUILD_BRANCH__
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
# 1. Clonar el repositorio
- name: Checkout project
uses: actions/checkout@v4
# 2. **Verificación entorno**
- name: Check for .env.production file
id: check_env
shell: pwsh
run: |
if (-not (Test-Path ".env.production")) {
Write-Error "Error: No existe el archivo '.env.production'. Recuerde que la aplicación se genera con el entorno de producción."
exit 1
}
# 3. Instalar Python 3.12
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# 4. Crear y activar entorno virtual
- name: Create virtual environment
shell: pwsh
run: |
python -m venv venv
.\venv\Scripts\Activate.ps1
python -m pip install --upgrade pip wheel setuptools
# 5. Instalar dependencias dentro del venv
- name: Install dependencies
shell: pwsh
run: |
.\venv\Scripts\Activate.ps1
# Instalación de requisitos explícitos
pip install PyQt6 PyQt6-WebEngine
pip install nicegui
pip install pydantic-settings
pip install pydantic
pip install pywebview
# Resto de requisitos
pip install -r requirements.txt -r requirements-extra-windows.txt
# PyInstaller al final
pip install pyinstaller
pip install --upgrade pyinstaller pyinstaller-hooks-contrib
# 6. Verificar paquetes instalados
- name: Verify key packages
shell: pwsh
run: |
.\venv\Scripts\Activate.ps1
python -c "import PyQt6; print('PyQt6 OK')"
python -c "import PyQt6.QtWebEngineWidgets; print('WebEngineWidgets OK')"
python -c "import nicegui; print('NiceGUI OK')"
# 7. Comprobar DLLs de Qt6
- name: Verify Qt6 DLLs
shell: pwsh
run: |
.\venv\Scripts\Activate.ps1
$pyqt6_path = (python -c "import PyQt6; print(PyQt6.__path__[0])")
$qt6_base = Join-Path $pyqt6_path "Qt6"
if (Test-Path "$qt6_base\bin") { echo "Qt6 bin exists"; dir "$qt6_base\bin" } else { echo "Qt6 bin not found" }
if (Test-Path "$qt6_base\plugins") { echo "Qt6 plugins exist"; dir "$qt6_base\plugins" } else { echo "Qt6 plugins not found" }
# 8. Instalar WebView2 runtime (para pywebview)
- name: Install WebView2 runtime
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "MicrosoftEdgeWebView2Setup.exe"
write-host "Lanzando MicrosoftEdgeWebView2Setup.exe en modo desatentido (silent)..."
Start-Process .\MicrosoftEdgeWebView2Setup.exe -ArgumentList "/silent","/install" -Wait
# 9. Compilar con PyInstaller
- name: Compile app
shell: pwsh
run: |
.\venv\Scripts\Activate.ps1
pyinstaller app/main.spec --noconfirm
echo "Build finished. Checking dist/"
dir dist
# 10. Copiar DLLs y plugins de Qt6 al dist
- name: Copy Qt6 runtime dependencies
shell: pwsh
run: |
.\venv\Scripts\Activate.ps1
$pyqt6_path = (python -c "import PyQt6; print(PyQt6.__path__[0])")
$QT6_PATH = Join-Path $pyqt6_path "Qt6"
$DEST = "dist\la_fragua"
Write-Host "Copying Qt6 from $QT6_PATH to $DEST"
Copy-Item "$QT6_PATH\bin\*.dll" -Destination "$DEST\PyQt6\Qt6\bin" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item "$QT6_PATH\plugins" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item "$QT6_PATH\resources" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$QT6_PATH\translations") {
Copy-Item "$QT6_PATH\translations" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force
}
# 11. Publicar artefacto en release
- name: Release build artifact
uses: softprops/action-gh-release@v1
with:
tag_name: "__BUILD_BRANCH__-${{ github.run_number }}"
generate_release_notes: true
files: dist/*.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- Ahora necesitamos crear el archivo
spec
para la configuración de creación del .exe:
# app/main.spec
# main.spec para compilación en windows
# -*- mode: python ; coding: utf-8 -*-
import os
import sys
import PyQt6
import PyQt6.QtWebEngineWidgets
import nicegui
from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules
# ---- Carpeta base del proyecto ----
project_dir = os.getcwd()
app_dir = os.path.join(project_dir, "app")
hookspath = [os.path.join(project_dir, 'extra-hooks')]
# ---- Recolectar archivos del proyecto ----
def collect_all_data(src_folder):
datas = []
for root, dirs, files in os.walk(src_folder):
for file in files:
if file.endswith('.pyc') or file.endswith('.env.production'):
continue
full_path = os.path.join(root, file)
rel_path = os.path.relpath(root, src_folder)
target_path = os.path.join(rel_path)
datas.append((full_path, target_path))
return datas
datas = collect_all_data(app_dir)
# ---- Qt6 DLLs y plugins ----
site_packages = sys.base_prefix + r"\Lib\site-packages"
qt6_base = os.path.join(site_packages, "PyQt6", "Qt6")
# DLLs (bin)
qt_bin = os.path.join(qt6_base, "bin")
if os.path.exists(qt_bin):
datas += [(os.path.join(qt_bin, f), "PyQt6/Qt6/bin") for f in os.listdir(qt_bin) if f.endswith(".dll")]
# Plugins (platforms, imageformats, etc.)
qt_plugins = os.path.join(qt6_base, "plugins")
for root, dirs, files in os.walk(qt_plugins):
for file in files:
full_path = os.path.join(root, file)
rel_path = os.path.relpath(root, qt6_base)
datas.append((full_path, f"PyQt6/Qt6/{rel_path}"))
# ---- NiceGUI recursos estáticos ----
datas += collect_data_files("nicegui", subdir="static")
datas += collect_data_files("nicegui", subdir="templates")
# Aquí cambiamos a .env.production
datas += [(os.path.join(project_dir, '.env.production'), '.')]
# ---- Icono opcional ----
icon_path = os.path.join(app_dir, 'assets', 'icon.ico')
if not os.path.exists(icon_path):
icon_path = None
# ---- Recoger todos los submódulos para pydantic y pydantic_settings ----
hidden_imports = collect_submodules('pydantic') + collect_submodules('pydantic_settings') + ["PyQt6.QtWebEngineWidgets"]
# ---- Análisis ----
a = Analysis(
[os.path.join(app_dir, "main.py")],
pathex=[project_dir],
binaries=[],
datas=datas,
hiddenimports=[
"PyQt6.QtWebEngineWidgets",
"PyQt6.QtWebEngineCore",
"PyQt6.QtWebChannel",
"PyQt6.QtPositioning",
] + hidden_imports,
hookspath=hookspath,
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name="la_fragua.exe",
debug=False,
strip=False,
upx=True,
console=True,
icon=icon_path,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name="la_fragua",
)
- También necesitamos el archivo 'hook-pydantic':
# extra-hooks/hook-pydantic.py
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = collect_submodules('pydantic') + collect_submodules('pydantic_settings')
- Y finalmente, añadimos a 'gitlab-ci' el
stage
necesario (línea 3-4):
- build
a continuación del stage 'test' le agregamos una nueva tarea:
launch-build-exe:
stage: build
image: alpine:latest
before_script:
- apk add --no-cache git zip unzip curl bash
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
# descarga de los archivos protegidos
- download-secure-files
- cp .secure_files/.env .
- cp .secure_files/.env.production .
script:
# empaquetar el código excluyendo .git
- zip -r project.zip . -x ".git/*" -x "tests/*" -x "KANBAN.md" -x "README.md" -x ".gitlab-ci.yml" -x ".gitignore"
# configurar git
- git config --global user.email "$GITLAB_CI_EMAIL"
- git config --global user.name "$GITLAB_CI_USER"
# clonar el repo de GitHub
- git clone https://oauth2:${GITHUB_TOKEN}@github.com/${GITHUB_USER}/${GITHUB_REPO}.git
- cd ${GITHUB_REPO}
- git checkout -B $BUILD_BRANCH
- rm -rf *
# mover proyecto
- mv ../project.zip .
- unzip project.zip
- rm project.zip
# añadir workflow con datos dinámicos
- mkdir -p .github/workflows
- sed -e "s|__BUILD_BRANCH__|$BUILD_BRANCH|g" -e "s|__CI_PROJECT_NAME__|$CI_PROJECT_NAME|g" ./ci/github-build.yml > .github/workflows/build.yml
- mv ./ci/README.github.md README.md
- rm -r ./ci
# commit & push
- git add .
- git commit -m "Trigger build from GitLab CI ${BUILD_BRANCH}"
- git push origin $BUILD_BRANCH --force
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE =~ /^\[build-exe\]/'
when: always
- when: manual
allow_failure: true
Configuración del entorno de Gitlab
Una vez actualizados los ficheros preparamos el entorno de Gitlab. Para ello, desde Gitlab:
-
Settings
>CI/CD Settings
>CI/CD Variables
, añadimos la variables:- BUILD_BRANCH (contenido: un nombre descriptivo para tu rama en GitHub)
- GITHUB_REPO (contenido: el nombre de tu repositorio en GitHub)
- GITHUB_USER (contenido: tu usuario de GitHub)
- GITLAB_CI_EMAIL (contenido: tu correo electronico)
- GITLAB_CI_USER (contenido: un alias descriptivo)
- GITHUB_TOKEN (contenido: tu token de GitHub)
- Actualizamos/añadimos los archivos protegidos:
-
Settings
>CI/CD Settings
>Secure files
: - .env
- .env.production
-
Una vez que has configurado totalmente las variables y archivos protegidos, ya puedes subir la rama a Gitlab:
git add .
git commit -m 'Virtualización'
git push -u origin 'release/000-virtualization'
Volvemos a Gitlab realizamos el Merge Request y lo fusionamos.
Una vez terminado solo nos queda actualizar nuestra rama local de 'main':
git checkout main
git pull
Comprobación
Si todo ha funcionado correctamente, en tu cuenta de GitHub se habrá lanzado una Action sobre tu repositorio, que te habrá generado un artefacto y un tag a dicho artefacto.
Para ver el artefacto debes pinchar sobre "Tag" y una vez dentro verás que también tienes "Releases", dentro de las cuales están los artefactos generados.
Apuntes
- En BUILD_BRANCH puedes poner, por ejemplo: 'el_repositorio-build_artifact'.
- En GITLAB_CI_EMAIL, puedes poner tu email o puedes utilizar un email que te indica GitHub. Lo tienes en los
Settings
de usuario, en el apartadoEmails
>Keep my email addresses private
, donde tras activarlo leerás en la explicación "We'll remove your public profile email and use [...@users.noreply.github.com]".
Enlaces
Repositorio del proyecto:
Top comments (0)