Práctica de CI/CD para aplicaciones WinForms / WPF — automatizar desde la compilación hasta la firma y la distribución con GitHub Actions

· Actualizado el: · · CI/CD, GitHub Actions, WinForms, WPF, C#, .NET, Firma de código, MSIX, Deployment, Desarrollo Windows, Tabla de decisión

«El release solo se puede compilar en el PC de esa persona» — es una frase que se escucha con mucha frecuencia en las consultas sobre aplicaciones de negocio en WinForms o WPF. Se compila en modo Release desde Visual Studio en el equipo local, se comprime en un zip y se coloca en una carpeta compartida. Funciona, sí, pero nadie puede responder si sería posible publicar una versión con la corrección de un error el día en que ese desarrollador esté de baja.

La información sobre CI/CD para aplicaciones web abunda, pero en cuanto se trata de aplicaciones de escritorio se vuelve escasa de inmediato. Existe una diferencia fundamental —el destino del despliegue no es un servidor, sino el PC del cliente— que impide copiar tal cual los artículos sobre web. Sin embargo, hasta la automatización de la compilación y las pruebas, una aplicación de escritorio puede montarse con casi el mismo esfuerzo que una aplicación web. El obstáculo aparece después, en la firma y la distribución, y ahí la solución realista cambia según el formato de distribución.

En este blog ya organizamos cómo elegir el formato de distribución en «Tabla de decisión para el formato de distribución de aplicaciones Windows», y el enfoque de la firma en «SmartScreen y la firma de código». Este artículo parte de esos dos y resume, desde una perspectiva práctica, hasta dónde automatizar con GitHub Actions la compilación, las pruebas, la numeración de versiones, la firma y la generación de artefactos de distribución de una aplicación WinForms / WPF.

Público objetivo y premisas

Para que pueda leer el artículo contrastándolo con su propia situación, antes enumeramos las condiciones que este artículo da por sentadas.

  • Público objetivo: desarrolladores o equipos que escriben aplicaciones de escritorio de Windows en WinForms / WPF y las distribuyen compilándolas en su Visual Studio local. No se requiere experiencia previa con CI/CD.
  • Repositorio: que el código fuente esté en un repositorio de GitHub (público o privado, es indiferente). En los repositorios públicos, los ejecutores estándar son gratuitos; en los privados, el tiempo de ejecución se factura por minuto.1
  • Formato del proyecto: el YAML de este artículo asume un csproj en formato SDK de .NET (net8.0-windows, etc.). Con un csproj de formato antiguo de .NET Framework 4.x, la idea es la misma, pero se usa MSBuild y la CLI de NuGet en lugar de dotnet build (capítulo 3).
  • Firma: el capítulo 5 se aborda desde “cómo obtener el certificado a partir de ahora”. La conclusión cambia según si ya cuenta con un archivo PFX o una CA interna, o si va a obtener un certificado público nuevo, así que lea contrastando con su situación actual.
  • Objetivo: no la distribución totalmente automatizada, sino primero llegar al estado en que “la compilación y las pruebas se puedan reproducir en el PC de cualquiera y se pueda extraer el artefacto” (capítulo 3). A partir de ahí se añaden, por etapas, la firma y la distribución.

La forma general del pipeline es la siguiente. Hasta dónde llega el alcance de la automatización se trata en el capítulo 2.

[Cotidiano] push / pull request hacia main
    └→ checkout → setup-dotnet → build → test → publish → upload-artifact (capítulo 3)

[Lanzamiento] push de una etiqueta v1.2.3
    └→ checkout → setup-dotnet → test
         → inyectar la versión desde la etiqueta y publish (capítulo 4)
         → firma (signtool / servicio de firma en la nube) (capítulo 5)
         → generar el artefacto de distribución (zip / MSI / MSIX / ClickOnce) (capítulo 6)
         → adjuntar a una versión (release) de GitHub para conservación a largo plazo (capítulo 4)

1. La conclusión, primero

  • El mayor riesgo es el estado de “solo se puede compilar en el PC del desarrollador”. El primer objetivo del CI/CD no es la automatización total de la distribución, sino que la compilación se pueda reproducir sin depender del PC de nadie.
  • La configuración mínima —solo automatizar la compilación y las pruebas— ya tiene valor de sobra. Se puede montar con un único archivo YAML: windows-latest + actions/checkout + actions/setup-dotnet + dotnet build / test + actions/upload-artifact.12
  • WinForms / WPF presupone un ejecutor de Windows. Como tienen como destino un TFM (target framework moniker) exclusivo de Windows como net8.0-windows3, si se quiere llegar hasta la ejecución de pruebas en el CI se necesita un entorno Windows.
  • La solución práctica para numerar versiones es basarse en etiquetas (tags). El push de una etiqueta v1.2.3 dispara la compilación de la versión, e inyecta el valor de la etiqueta en la propiedad Version de MSBuild.4
  • La firma es el mayor obstáculo para la automatización. Desde junio de 2023, la clave privada de un certificado OV público debe guardarse obligatoriamente en un HSM, por lo que el método clásico de “PFX en un secreto + signtool” ya no funciona tal cual. Azure Artifact Signing (antes Trusted Signing), que facilita la integración con el CI, no incluye a Japón entre sus regiones habilitadas, así que la solución realista para los desarrolladores en Japón es la opción de HSM en la nube de la CA, o bien dejar solo el paso de firma en un equipo local (sección 5.2).5
  • La facilidad de integración en el CI varía mucho según el formato de distribución. El orden es: xcopy (zip) es el más simple, MSIX requiere firma obligatoria6, MSI se integra vía CLI con herramientas como WiX, y ClickOnce necesita msbuild /target:publish y tiene particularidades notables.7
  • No convertir las pruebas automáticas de UI en un requisito obligatorio del CI. La solución realista es exigir las pruebas unitarias en el CI, y limitar las pruebas de UI a pruebas de humo (smoke tests) ejecutadas en un job aparte.

2. En qué se diferencia el CI/CD de una aplicación de escritorio del de una aplicación web

Antes de nada, aclaremos por qué no se puede trasladar tal cual el patrón de CI/CD de una aplicación web (push → compilación → pruebas → despliegue en el servidor).

Aspecto Aplicación web Aplicación de escritorio WinForms / WPF
Destino del despliegue Un servidor que administramos nosotros El PC del cliente o del sitio (fuera de nuestro control)
Unidad de distribución Cambio simultáneo en el servidor Diversa: MSI / MSIX / ClickOnce / zip, etc. El momento de la implantación depende del destinatario
Reversión (rollback) Se puede revertir desde el lado del servidor Difícil de revertir desde un PC ya distribuido. Es imprescindible conservar los instaladores de versiones anteriores
Firma Normalmente no es necesaria (el TLS corresponde a la infraestructura) La firma de código del ejecutable y del paquete es, en la práctica, obligatoria
Entorno de compilación Suele completarse con un ejecutor Linux Presupone un ejecutor de Windows
Pruebas Suele completarse en modo headless Las pruebas unitarias son iguales. Las pruebas de UI requieren una sesión de escritorio
Significado de “despliegue” Hasta reflejarse en producción El alcance del CI llega hasta “tener listo el artefacto de distribución”. La instalación es un proceso aparte

Lo importante es la última fila. En una aplicación de escritorio, la salida del pipeline de CI/CD no es “reflejarse en producción”, sino “que el artefacto de distribución firmado esté disponible en un lugar del que se pueda extraer en cualquier momento”. Lo que viene después (la implantación en el cliente, la actualización automática) es cuestión del diseño del formato de distribución, un terreno tratado en «Tabla de decisión para el formato de distribución». Dicho de otro modo, si se acota la salida de esa manera, el CI/CD de una aplicación de escritorio se puede montar con el mismo conjunto de herramientas que el de una aplicación web.

3. Configuración mínima — compilación y pruebas con GitHub Actions

Esto es todo lo que hay que introducir primero. Como los ejecutores hospedados por GitHub asignan una VM nueva en cada job1, en cada push la compilación y las pruebas se ejecutan sobre un Windows limpio, y la dependencia de “un SDK que solo está instalado en el PC de tal persona” sale a la luz en el acto.

name: build-and-test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: windows-latest   # WinForms / WPF requiere un ejecutor de Windows
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build

      - name: Publish
        run: dotnet publish src/MyApp/MyApp.csproj -c Release -o publish

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp
          path: publish

Si coloca este YAML como .github/workflows/build-and-test.yml, funcionará tal cual con cada push. Sin embargo, reemplace la parte src/MyApp/MyApp.csproj por la ruta del proyecto de su propio repositorio. En los YAML siguientes se usa la misma ruta como ejemplo. Si en la raíz del repositorio solo hay una solución y un único csproj, también funciona omitiendo la ruta, como en dotnet publish -c Release -o publish.

A continuación, tres puntos adicionales.

En primer lugar, runs-on: windows-latest es la base. Un proyecto WinForms / WPF tiene un TargetFramework con un TFM exclusivo de Windows como net8.0-windows, y es un proyecto del SDK de escritorio de .NET con UseWindowsForms o UseWPF habilitado.3 En rigor, si solo se trata de compilar, también se puede construir en un ejecutor Linux habilitando EnableWindowsTargeting, pero como los pasos que implican ejecución, como dotnet test, necesitan un entorno Windows, en esta configuración que ejecuta también las pruebas en un solo job se usa directamente un ejecutor de Windows. Con .NET Framework 4.x (csproj de formato antiguo) se usan MSBuild y la CLI de NuGet en lugar de dotnet build, pero ambos vienen preinstalados en el ejecutor de Windows y la idea es la misma.

En segundo lugar, siempre se conservan los artefactos con actions/upload-artifact. Que “todo el conjunto de artefactos de esa compilación se pueda extraer desde GitHub” es, en esencia, dejar de depender de un PC concreto. Incluso una versión de verificación urgente se reduce a descargar un zip desde la pantalla de Actions.

En tercer lugar, en esta etapa todavía no se hace ni la firma ni la distribución. Con solo esta configuración mínima se obtienen dos garantías: “main siempre se puede compilar y probar” y “cualquiera puede extraer el mismo artefacto”; según la experiencia del autor, esto resuelve la mayoría de los problemas de los equipos pequeños.

4. Numeración automática de versiones — lanzamientos basados en etiquetas (tags)

La siguiente etapa consiste en resolver el problema de “¿qué versión es este zip?”. En la operación con compilaciones locales, es habitual el accidente de olvidar cambiar Version en el csproj, de modo que existen varias generaciones con el mismo 1.0.0. La solución práctica es el lanzamiento basado en etiquetas: al poner una etiqueta como v1.2.3 en el commit que se quiere publicar, esa etiqueta dispara un flujo de trabajo que inyecta en la compilación la versión extraída del nombre de la etiqueta.

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      # El push de una etiqueta no dispara el flujo de trabajo de compilación
      # y pruebas del capítulo 3, así que aquí también se ejecutan las
      # pruebas antes de generar los artefactos de la versión
      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')   # v1.2.3 -> 1.2.3
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp-${{ github.ref_name }}
          path: publish

Al pasarlo como propiedad de MSBuild, por ejemplo -p:Version=1.2.3, en un proyecto del SDK de .NET, AssemblyVersion y FileVersion se generan por defecto a partir del prefijo de Version (la parte sin el sufijo), y InformationalVersion a partir del propio Version.4 En el csproj solo se deja un valor provisional para desarrollo, y la gestión queda centralizada: la versión oficial de cada lanzamiento la posee únicamente la etiqueta.

Hay una advertencia. Los artefactos de actions/upload-artifact tienen un periodo de retención del repositorio (90 días por defecto) y desaparecen al vencer. Como en una aplicación de escritorio es necesario conservar a largo plazo los instaladores de versiones anteriores para poder revertir, los artefactos de las compilaciones con etiqueta se publican en un lugar permanente, como adjuntándolos a una versión (release) de GitHub, y los artefactos de Actions se asumen como un traspaso temporal.

Para adjuntar a una versión de GitHub hay dos métodos: usar una acción dedicada, o usar la GitHub CLI (gh), que ya viene incluida en el ejecutor.8 Ambos requieren el permiso contents: write en el job.

jobs:
  release:
    runs-on: windows-latest
    permissions:
      contents: write        # necesario para crear la versión y adjuntar los activos
    steps:
      # ...(compilación y publish como se mostró antes)

      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      # Método A: usar una acción dedicada
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

      # Método B: usar la GitHub CLI incluida en el ejecutor (basta con uno de los dos métodos)
      - name: Create GitHub Release (gh)
        shell: pwsh
        run: gh release create ${{ github.ref_name }} MyApp-${{ github.ref_name }}.zip --generate-notes
        env:
          GH_TOKEN: ${{ github.token }}

gh viene preinstalado en los ejecutores hospedados por GitHub, pero en cada paso hay que pasar a la variable de entorno GH_TOKEN un token con el alcance (scope) necesario.8

La ventaja es que toda la operación queda contenida en Git. La pregunta “¿qué commit corresponde a la versión 1.2.3 en el entorno del cliente?” queda fijada por la etiqueta, y la versión de archivo que aparece en las propiedades del EXE coincide mecánicamente con la etiqueta de Git. Además, desde el SDK de .NET 8 en adelante, el hash del commit de Git (SourceRevisionId) se añade por defecto a InformationalVersion4, así que si se muestra este valor en la pantalla de versión, es posible identificar el commit directamente a partir del artefacto.

5. Integrar la firma de código en el CI — el mayor obstáculo

Los equipos que consiguen automatizar sin problemas hasta la compilación y el versionado casi siempre se detienen en la firma. Las razones por las que la firma de código es, en la práctica, obligatoria para una aplicación Windows distribuida fuera de la Store (SmartScreen, productos de seguridad corporativos, detección de manipulación) ya se organizaron en «SmartScreen y la firma de código», así que aquí nos limitamos a en qué punto del CI se ejecuta y cómo.

5.1 Forma básica de signtool

La ejecución de la firma en sí es un solo comando. signtool viene incluido en el Windows SDK y también está disponible en el ejecutor de Windows de GitHub. En el SDK actual es obligatorio especificar /fd (resumen del archivo) y /td (resumen de la marca de tiempo), y se recomienda SHA256.9

signtool sign /f MyCert.pfx /p $env:PFX_PASSWORD `
  /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
  publish\MyApp.exe

La marca de tiempo (/tr) es opcional, pero siempre debe incluirse. Con ella, incluso después de que el certificado caduque se puede verificar que “en el momento de la firma era válido”, y la firma de los archivos ya distribuidos sigue vigente.96

5.2 Tipos de certificado y la realidad de la integración en el CI

El problema no es el comando, sino dónde se guarda la clave privada. Según la forma en que se obtenga el certificado, la manera de integrarlo en el CI cambia radicalmente.

Forma del certificado Ubicación de la clave privada Integración en el CI Notas
Servicio de firma en la nube (Azure Artifact Signing = antes Trusted Signing, etc.) En el lado de la nube Fácil de integrar. Diseñado presuponiendo la integración con GitHub Actions y similares Tiene restricciones de país/región disponibles y Japón queda fuera (personas jurídicas: EE. UU., Canadá, UE, Reino Unido; personas físicas: solo EE. UU. y Canadá). Detalles más abajo5
Certificado OV (emitido de nuevo a partir de junio de 2023) HSM / token USB obligatorio No es posible tal cual, porque el token no se puede insertar en el ejecutor. Sí es posible con la opción de HSM en la nube de la CA Por requisito del CA/Browser Forum5
Certificado EV HSM / token USB Igual que arriba El efecto de confianza inmediata en SmartScreen se eliminó en 2024. A efectos de operación de la firma, se considera al mismo nivel que OV5
Archivo PFX tradicional (emitido en el pasado, CA interna, autofirmado) Archivo Se guarda en Base64 en un secreto y se restaura (ver más abajo) Para una obtención nueva destinada a distribución pública, en principio este formato ya no está disponible

Es decir, la configuración de “PFX en un secreto de GitHub + firma con signtool”, que aparece con frecuencia en las búsquedas, sigue siendo válida con una CA interna o un PFX existente, pero la premisa se derrumba en el caso de obtener un certificado público a partir de ahora. Si se monta desde cero, lo realista es plantearlo en torno a un servicio de firma en la nube que soporte la integración con el CI desde el principio.5 Si se está operando con un token USB, la configuración de compromiso consiste en dejar solo el paso de firma en el PC local o en un ejecutor autohospedado con el token insertado.

La pregunta central para los desarrolladores en Japón — ¿se puede usar Azure Artifact Signing?

En la primera fila de la tabla se indicó “tiene restricciones de país/región disponibles”, pero para los lectores en Japón este es el criterio de decisión más importante, así que lo tratamos aparte.

La documentación de Microsoft especifica claramente que Azure Artifact Signing (antes Trusted Signing) solo está disponible, para personas jurídicas, en EE. UU., Canadá, la UE y el Reino Unido, y para desarrolladores individuales, en EE. UU. y Canadá.5 Es decir, las empresas japonesas y los desarrolladores individuales residentes en Japón quedan, por ahora, fuera del alcance. Si se traslada tal cual la idea general de que “la firma en la nube es la solución ideal”, el proceso se detiene ya en la etapa de crear la cuenta. Las opciones que parten de esta restricción son las siguientes.

Situación Opción realista
Va a obtener un certificado público a partir de ahora (empresa japonesa) Certificado OV + opción de HSM en la nube de la CA. Desde junio de 2023, la clave privada de un certificado OV debe guardarse obligatoriamente en un HSM o token de hardware, pero muchas CA ofrecen, además del token USB, la opción de HSM en la nube, con la cual sí se puede invocar la firma desde el CI.5 Si se tiene previsto integrarlo en el CI, confirme con la CA, en la etapa de elegir el certificado, si soporta HSM en la nube. Una vez comprado el token, ya no se puede cambiar
Ya opera con un token USB Deje solo el paso de firma en un PC local o en un ejecutor autohospedado con el token insertado. Es una configuración en la que la compilación, las pruebas y el versionado se automatizan en un ejecutor hospedado por GitHub, y solo la firma final conserva la intervención humana
Puede distribuirse a través de Microsoft Store (MSIX) Microsoft vuelve a firmar del lado de la Store, por lo que no hace falta un certificado propio.5 Si se puede reconsiderar el formato de distribución, es la ruta más corta para que desaparezca por completo la preocupación de la firma (aunque si se publica en la Store con un instalador MSI/EXE, sí se necesita la firma del lado del publisher)
Proyecto de código abierto SignPath Foundation ofrece firma de código gratuita a los proyectos OSS que cumplan ciertas condiciones.5
Solo distribución interna Basta con un certificado emitido por una CA interna y el método PFX existente (sección 5.3). Si el entorno permite distribuir el certificado como raíz de confianza mediante directiva de grupo o Intune, no hace falta un certificado público

Las regiones cubiertas por Azure Artifact Signing podrían ampliarse en el futuro, así que al fijar el diseño de la firma en el CI/CD, confirme en la fuente primaria las regiones vigentes en ese momento.5

5.3 Precauciones en la gestión de secretos

Estas son las prácticas estándar al integrar el método PFX (CA interna, certificado existente) en el CI.

  • El PFX se convierte en una cadena Base64 y se guarda en un secreto de GitHub, y dentro del job se restaura a un archivo. Es el procedimiento que documenta GitHub Docs para manejar binarios como secreto.10
  • La contraseña se guarda en un secreto aparte. El valor de un secreto se enmascara automáticamente en los registros10, pero eso no protege los valores derivados que se hayan procesado. Evite pasar la variable de entorno a pasos distintos del de la firma.
  • A las pull requests procedentes de un fork no se les pasan los secretos (salvo GITHUB_TOKEN).10 Sin embargo, el propio job se ejecuta con secretos vacíos, así que el paso de restauración anterior falla al decodificar en Base64 una cadena vacía. Conviene separar el paso de firma en un flujo de trabajo de lanzamiento disparado por etiquetas como el del capítulo 4 (que no se dispara en una PR de fork), o bien omitirlo explícitamente con una condición como if: github.event_name != 'pull_request'.
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

5.4 Flujo de trabajo completo del método PFX

A continuación se muestra cómo encadenar en uno solo los fragmentos vistos hasta ahora (inyección de versión basada en etiquetas, restauración del PFX, ejecución de signtool y publicación de artefactos). Es una configuración que presupone tener una CA interna o un PFX existente; si la coloca tal cual en .github/workflows/release.yml de su repositorio, funcionará. Reemplace la ruta del proyecto y la URL del servidor de marca de tiempo por las de su propio entorno.

name: release

on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: windows-latest
    # El job que toca la clave de firma debe vincularse a un Environment con
    # aprobadores (sección 5.5). Si se omite esto y se deja la clave como
    # secreto del repositorio, cualquiera con permiso de escritura (o una
    # cuenta comprometida) puede reescribir el flujo de trabajo y hacer
    # push de una sola etiqueta para extraer la clave de firma. Se indica
    # este environment, y SIGNING_PFX_BASE64 y SIGNING_PFX_PASSWORD se
    # colocan en el propio Environment
    environment: release-signing
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Test
        run: dotnet test --configuration Release

      - name: Publish with version from tag
        shell: pwsh
        run: |
          $version = $env:GITHUB_REF_NAME.TrimStart('v')
          dotnet publish src/MyApp/MyApp.csproj `
            -c Release -o publish `
            -p:Version=$version

      # --- A partir de aquí, la firma ---
      - name: Restore signing certificate
        shell: pwsh
        run: |
          $bytes = [Convert]::FromBase64String($env:PFX_BASE64)
          [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\sign.pfx", $bytes)
        env:
          PFX_BASE64: ${{ secrets.SIGNING_PFX_BASE64 }}

      - name: Sign
        shell: pwsh
        run: |
          # signtool viene incluido en el Windows SDK. Se resuelve la ruta
          # en vez de fijarla
          $signtool = Get-ChildItem `
            "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
            Sort-Object FullName | Select-Object -Last 1

          # Firmar no solo el exe, sino también las DLL de compilación propia
          # que van dentro del paquete distribuido.
          # Si el destino usa reglas de editor de App Control / AppLocker que
          # también revisan las DLL, basta con una DLL sin firmar para que
          # se detenga ahí.
          #
          # Sin embargo, el objetivo se limita solo a los ensamblados propios.
          # publish también contiene DLL de NuGet o del framework, así que si
          # se capturan todas con un comodín, se sobrescribe la firma del
          # proveedor con el certificado propio (un sign sin /as reemplaza la
          # firma existente) y se distribuyen DLL de terceros sin firmar como
          # si fueran "producto propio". Esto rompe las reglas de permiso
          # basadas en el editor y la verificación de procedencia, así que se
          # enumeran explícitamente por nombre
          $ownAssemblies = @('MyApp', 'MyApp.Core', 'MyApp.Plugins')
          $targets = Get-ChildItem publish -Recurse -Include *.exe, *.dll |
            Where-Object { $ownAssemblies -contains $_.BaseName } |
            Select-Object -ExpandProperty FullName
          if (-not $targets) { throw 'No se encontraron objetivos de firma. Revise el contenido de publish.' }

          & $signtool.FullName sign `
            /f "$env:RUNNER_TEMP\sign.pfx" /p $env:PFX_PASSWORD `
            /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
            $targets
          if ($LASTEXITCODE -ne 0) { throw "La firma falló (exit $LASTEXITCODE)" }
        env:
          PFX_PASSWORD: ${{ secrets.SIGNING_PFX_PASSWORD }}

      - name: Remove certificate
        if: always()
        shell: pwsh
        run: Remove-Item "$env:RUNNER_TEMP\sign.pfx" -ErrorAction SilentlyContinue

      # --- A partir de aquí, los artefactos ---
      - name: Zip
        shell: pwsh
        run: Compress-Archive -Path publish\* -DestinationPath MyApp-${{ github.ref_name }}.zip

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v3
        with:
          files: MyApp-${{ github.ref_name }}.zip

Al leerlo, hay cinco puntos clave.

  • No quite environment: release-signing. Si la clave de firma se deja como secreto del repositorio, cualquiera con permiso de escritura en el repositorio puede extraerla con solo reescribir el flujo de trabajo y hacer push de una etiqueta. Coloque el secreto en un Environment con aprobadores, de modo que solo este job pueda referenciarlo (sección 5.5). No opere como “versión terminada” un flujo de trabajo que carezca de esta línea.
  • Limite el objetivo de la firma solo a los ensamblados propios. En publish también hay DLL de paquetes de dependencia. Si se firman todas en bloque, se termina reemplazando la firma del proveedor con el certificado propio y distribuyendo DLL de terceros sin firmar como si fueran producto propio. Si de verdad hace falta añadir una firma a un binario de terceros, hágalo con /as para añadirla, no para reemplazarla.
  • La firma va después de dotnet publish y antes de la compresión. Si se firma después de empaquetar en un zip, el EXE de dentro no queda firmado. Al generar un MSI o un MSIX también se sigue el orden de firmar primero el EXE/DLL interno, luego crear el paquete y, por último, firmar el propio paquete.
  • Elimine el PFX en cuanto termine de usarse. Se añade if: always() para que el paso de eliminación se ejecute incluso si la firma falla. No es estrictamente necesario en un ejecutor hospedado por GitHub, ya que se destruye después de cada job1, pero se convierte en un incidente si más adelante se traslada a un ejecutor autohospedado.
  • Este flujo de trabajo solo se ejecuta con el push de una etiqueta. Como no se dispara en una pull request procedente de un fork, se evita estructuralmente el problema de secretos vacíos mencionado en la sección 5.3.

Si se usa un servicio de firma en la nube o un HSM en la nube, los dos pasos «Restore signing certificate» y «Sign» simplemente se reemplazan por la acción o la llamada a la CLI que ofrezca el servicio; el resto de la forma no cambia.

5.5 Limitar los flujos de trabajo que acceden a la clave de firma

Otro punto crítico, a la par del manejo de secretos (sección 5.3), es limitar los flujos de trabajo que pueden acceder a la clave de firma. Como quien tiene permiso de escritura en el repositorio puede reescribir un flujo de trabajo, el secreto de firma se coloca en un Environment con aprobadores, de modo que solo el flujo de trabajo de lanzamiento pueda referenciarlo. Un binario firmado es, en sí mismo, la prueba de que “lo hicimos nosotros”, así que el manejo de la clave debe diseñarse con el mismo nivel de frontera de confianza que la infraestructura de distribución de actualizaciones automáticas (sobre este enfoque, véase «Seguridad de las actualizaciones automáticas»).

6. Tabla de decisión — integración de CI/CD según el formato de distribución

Una vez resuelta la firma, lo último es la forma del artefacto de distribución. La selección del formato en sí se remite a «Tabla de decisión para el formato de distribución»; aquí se compara solo desde el punto de vista del CI/CD.

Formato de distribución Facilidad de generación en el CI Medio de generación en el CI Requisito de firma Actualización automática
xcopy (distribución zip) La más simple Solo dotnet publish + compresión Firma del EXE/DLL (recomendada) Ninguna (implantación manual)
xcopy + actualizador propio Simple (la compilación). El diseño de la distribución de actualizaciones es, aparte, pesado dotnet publish + generación del manifiesto Firma del EXE + diseño de verificación del archivo de actualización obligatorios Propio (requiere diseñar la frontera de confianza)
MSI Media Ejecutar por CLI herramientas como WiX Firma del archivo MSI (recomendada, casi obligatoria) Ninguna (se necesita un mecanismo de distribución aparte)
MSIX Media MSBuild / MakeAppx + signtool La firma del paquete es obligatoria (no se puede instalar sin firmar)6 Se puede atender con App Installer, etc.
ClickOnce Muy particular msbuild /target:publish + perfil de publicación (no soportado por la CLI de dotnet)7 Firma del manifiesto + firma del EXE Integrada (el propósito principal del formato)

Algunas aclaraciones.

  • xcopy (zip): los flujos de trabajo de los capítulos 3 y 4 son, casi tal cual, la versión terminada. Sea cual sea el formato de distribución final, pasar primero por esta forma es el atajo.
  • MSI: se incluye la definición del instalador (WiX, etc.) en el repositorio y se compila con la CLI. Más que la generación en sí, lo central es el diseño de “qué se incluye en el MSI” (registro de servicios, per-machine/per-user).
  • MSIX: Windows no permite instalar un MSIX sin firmar, así que la integración en el CI no queda completa a menos que vaya de la mano de la automatización de la firma.6 A la inversa, si ya se cuenta con una infraestructura de firma, es un formato fácil de integrar en el CI. Si se distribuye a través de Microsoft Store, existe la alternativa de que la Store vuelva a firmar, con lo que no hace falta un certificado propio.5
  • ClickOnce: no se puede publicar desde la CLI de dotnet; se usa msbuild /target:publish /p:PublishProfile=... especificando un perfil de publicación (.pubxml). Como el número de revisión (ApplicationRevision), que en el IDE se incrementa automáticamente en cada publicación, no se incrementa en la línea de comandos7, es obligatorio el diseño del capítulo 4 que pasa la versión explícitamente mediante etiquetas. Un detalle importante: la detección de actualizaciones de ClickOnce no se basa en -p:Version (información del ensamblado), sino en la versión del lado del despliegue (ApplicationVersion / ApplicationRevision), así que si no se pasa por separado el valor en formato de cuatro partes construido a partir de la etiqueta, como en /p:ApplicationVersion=1.2.3.0, el nuevo lanzamiento no se reconoce como una actualización. El mecanismo y en qué casos conviene o no se explican en «Qué es ClickOnce».

Solo desde el punto de vista del CI/CD, “empezar con zip y, cuando se afiancen los requisitos de distribución, añadir MSIX o MSI como job” es el enfoque de menor incremento. Como la etapa previa (compilación, pruebas, versión) es común a todos los formatos, sustituir después el paso del formato de distribución no desperdicia lo ya construido.

7. Hasta dónde llevar la automatización de pruebas

Por último, dónde trazar la línea de cuánta prueba exigir como puerta de control (chequeo obligatorio) del CI.

Capa de prueba Trato en el CI Motivo
Pruebas unitarias (lógica) Puerta obligatoria. Se ejecutan en cada pull request Rápidas, estables, funcionan tal cual en el ejecutor de Windows
Pruebas de integración sin pantalla (BD, E/S de archivos) Obligatorias en principio. Si son lentas, se separan a una ejecución nocturna Requieren cierto ingenio para inicializar las dependencias externas, pero el valor de automatizarlas es alto
Pruebas automáticas de UI (humo) Solo unas pocas, en un job aparte. Del arranque hasta las principales transiciones de pantalla Requieren una sesión de escritorio y tienen muchos factores de inestabilidad
Pruebas automáticas de UI (exhaustivas) No se convierten en puerta del CI El costo de mantenimiento suele superar al beneficio

En una aplicación de escritorio, el valor de automatizar más alto no está en la UI, sino por debajo de ella. Si la lógica de negocio queda enterrada en el code-behind, no se pueden escribir pruebas unitarias, así que separar la lógica de la pantalla es, en sí mismo, la inversión previa que exige el CI/CD. Lo realista es limitar las pruebas automáticas de UI a una prueba de humo del tipo “arrancar, iniciar sesión y que se abran las pantallas principales”, y ejecutarla con otro disparador, como uno nocturno. Las pruebas de UI sobre un ejecutor tienen muchas trampas relacionadas con la sesión de pantalla, la resolución y los tiempos; este terreno se trata, incluyendo las trampas del CI y la ejecución desatendida, en «Pruebas automáticas de UI para aplicaciones de escritorio de Windows».

8. Resumen

  • Si se define la salida del CI/CD de una aplicación de escritorio como “la finalización del artefacto de distribución firmado”, se puede montar con el mismo conjunto de herramientas que el de una aplicación web.
  • La configuración mínima es windows-latest + actions/checkout + actions/setup-dotnet + dotnet build / test + actions/upload-artifact. Con esto basta para eliminar el riesgo de “solo se puede compilar en el PC del desarrollador”.12
  • WinForms / WPF presupone un ejecutor de Windows, porque usa un TFM exclusivo de Windows como net8.0-windows.3
  • La versión se centraliza con el enfoque basado en etiquetas: etiqueta v1.2.3 → inyección con -p:Version.4
  • La firma es el mayor obstáculo para la automatización del CI. Ahora que el certificado OV también exige almacenamiento en HSM, y como Azure Artifact Signing deja fuera a Japón de sus regiones cubiertas, el punto de entrada práctico es confirmar la opción de HSM en la nube de la CA en la etapa de selección del certificado. El método PFX + secreto está orientado a una CA interna o un certificado existente, y en la sección 5.4 se presentó el flujo de trabajo completo.510
  • Los artefactos de las compilaciones con etiqueta se adjuntan a una versión de GitHub, para conservación a largo plazo, con softprops/action-gh-release o con gh release create incluido en el ejecutor (el job necesita contents: write).8
  • La facilidad de integrar el formato de distribución en el CI sigue el orden xcopy (zip) → MSI / MSIX → ClickOnce. MSIX requiere firma obligatoria6, y en ClickOnce hay que prestar atención a msbuild /target:publish y a que la revisión no se incrementa automáticamente.7
  • Las pruebas unitarias, como puerta obligatoria del CI; las pruebas de UI, limitadas a pruebas de humo en un job aparte. Separar la lógica de la pantalla es la inversión previa.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC, además del desarrollo de aplicaciones WinForms / WPF, se ocupa de la migración desde una operación con compilaciones locales hacia CI/CD, del diseño de pipelines de compilación, firma y distribución con GitHub Actions, y de hacer testeables (separación de la lógica) las aplicaciones de escritorio existentes.

Referencias

  1. GitHub Docs, GitHub-hosted runners reference. Sobre las etiquetas de ejecutor como windows-latest, que se asigna una máquina virtual nueva en cada job, y que en los repositorios públicos los ejecutores estándar son gratuitos.  2 3 4 5

  2. Microsoft Learn, GitHub Actions and .NET. Sobre el CI/CD de .NET con GitHub Actions, el papel de actions/checkout y actions/setup-dotnet, y el uso de dotnet restore / build / test / publish dentro del flujo de trabajo.  2

  3. Microsoft Learn, MSBuild reference for .NET Desktop SDK projects. Sobre que los proyectos WinForms / WPF especifican un TFM propio de Windows como net8.0-windows, y habilitan el SDK de escritorio de .NET con UseWindowsForms / UseWPF 2 3

  4. Microsoft Learn, Set assembly attributes in a project file. Sobre que AssemblyVersion / FileVersion (sin el sufijo) e InformationalVersion se generan por defecto a partir de la propiedad Version, y que desde el SDK de .NET 8 se añade SourceRevisionId (el hash del commit) a InformationalVersion 2 3 4

  5. Microsoft Learn, Code signing options for Windows app developers. Sobre que desde junio de 2023 el requisito del CA/Browser Forum obliga a guardar la clave privada de un certificado OV en un HSM/token de hardware, que la exención inmediata de SmartScreen para certificados EV se eliminó en 2024, que Azure Artifact Signing (antes Trusted Signing) no necesita token y se integra con GitHub Actions y similares pero tiene restricciones de región, y que en la distribución MSIX de la Store Microsoft vuelve a firmar.  2 3 4 5 6 7 8 9 10 11 12

  6. Microsoft Learn, Sign an MSIX package. Sobre que Windows exige una firma de código válida para los paquetes MSIX, y que la marca de tiempo mantiene válida la verificación de la firma incluso después de que caduque el certificado.  2 3 4 5

  7. Microsoft Learn, Build .NET ClickOnce applications from the command line. Sobre que la publicación de ClickOnce en .NET requiere msbuild /target:publish con un perfil de publicación especificado, y que ApplicationRevision no se incrementa automáticamente en las compilaciones por línea de comandos.  2 3 4

  8. GitHub Docs, Using GitHub CLI in workflows. Sobre que la GitHub CLI (gh) viene preinstalada en todos los ejecutores hospedados por GitHub, y que en cada paso que use gh hay que configurar en la variable de entorno GH_TOKEN un token con el alcance necesario.  2 3

  9. Microsoft Learn, SignTool. Sobre que SignTool viene incluido en el Windows SDK, que en las compilaciones actuales es obligatorio especificar /fd y /td y se recomienda SHA256, y sobre la especificación de la marca de tiempo RFC 3161 con /tr 2

  10. GitHub Docs, Using secrets in GitHub Actions. Sobre el enmascarado automático de los valores de secretos en los registros, el procedimiento para guardar binarios como certificados en Base64 en un secreto y restaurarlos dentro del job, y que a los flujos de trabajo disparados desde un fork no se les pasan los secretos.  2 3 4

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿En qué ejecutor (runner) de GitHub Actions debería compilarse una aplicación WinForms / WPF?
Se utiliza un ejecutor de Windows como windows-latest. Los proyectos WinForms / WPF tienen como destino un framework objetivo (target framework) exclusivo de Windows, como net8.0-windows, por lo que se necesita un entorno Windows tanto para verificar el funcionamiento de los artefactos compilados como para ejecutar las pruebas con dotnet test. Los ejecutores hospedados por GitHub asignan una máquina virtual nueva en cada job, lo que produce compilaciones reproducibles que no dependen del PC de ningún desarrollador. En los repositorios públicos, los ejecutores estándar son gratuitos; en los privados, el tiempo de ejecución se factura por minuto.
¿Se puede automatizar por completo la firma de código en el CI?
Depende de cómo se tenga el certificado. El método clásico de colocar un archivo PFX en un secreto y firmar con signtool ya no es viable, en principio, para certificados obtenidos recientemente, porque desde junio de 2023 los requisitos del CA/Browser Forum obligan a que la clave privada de un certificado OV público se guarde en un HSM (hardware). Los certificados en formato de token USB no se pueden insertar en un ejecutor en la nube, así que para automatizar por completo la firma en el CI, lo realista es recurrir a un servicio de firma en la nube como Azure Artifact Signing (antes Trusted Signing) o a la opción de HSM en la nube que ofrece la CA. Si se sigue operando con un token, la configuración consiste en dejar únicamente el paso de firma en un equipo local o en un ejecutor autohospedado (self-hosted).
¿Por dónde conviene empezar a automatizar?
Conviene introducir primero, y únicamente, la automatización de la compilación y las pruebas. Basta con lograr que dotnet build / dotnet test se ejecuten en un ejecutor windows-latest en cada push para eliminar el mayor riesgo: 'solo se puede compilar en el PC de tal desarrollador' y 'no nos damos cuenta de que un merge rompió la compilación hasta justo antes de distribuir'. La automatización de la firma, la creación del instalador y la distribución se puede añadir después, de forma gradual; intentar montarlo todo desde el principio suele detenerse en la parte de la firma.
¿Cambia la facilidad de integrar el CI/CD según el formato de distribución (MSI / MSIX / ClickOnce / xcopy)?
Cambia mucho. La distribución xcopy (zip) es la más sencilla, porque solo consiste en comprimir la salida de dotnet publish. MSIX puede integrarse en el CI con MSBuild y signtool, pero la firma del paquete es obligatoria. MSI puede automatizarse invocando desde el CI herramientas como WiX. ClickOnce no se puede publicar con la CLI de dotnet: requiere combinar msbuild /target:publish con un perfil de publicación, y hay que tener en cuenta que en la línea de comandos el número de revisión no se incrementa automáticamente. Al decidir el formato de distribución conviene incluir la facilidad de integración en el CI como criterio.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog