name: Windows MSI (WiX v7) on: # Triggered automatically after a successful Windows build on master workflow_run: workflows: ["Windows Build"] types: [completed] branches: [master] # Manual trigger still available (e.g. for a specific run_id) workflow_dispatch: inputs: run_id: description: "Run ID of 'Windows Build' (leave empty for automatic)" required: false default: "" jobs: build-msi: name: Build MSI with WiX v7 (${{ matrix.flavor }}) runs-on: windows-latest # Only runs if Windows Build succeeded (or triggered manually) if: > github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' strategy: fail-fast: false # Single-entry matrix kept on purpose (rather than flattening the job): # matrix.flavor is used as part of the MSI ProductCode seed, so changing # it would generate a new ProductCode and break upgrade detection for # existing installs. Keeping "qt6" here preserves continuity. matrix: include: - flavor: qt6 portable_artifact: qelectrotech-windows-portable-qt6 version_source: hardcoded # see note in "Extract version" step label_suffix: "-qt6" experimental: false permissions: contents: write pages: write id-token: write # Required by SignPath env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} outputs: qt6_msi: ${{ steps.export.outputs.msi_name_qt6 }} steps: # ---------------------------------------------------------------- # 1. Checkout (to retrieve QElectroTech.wxs and sources) # ---------------------------------------------------------------- - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 # ---------------------------------------------------------------- # 2. Download the portable artifact for this flavor # ---------------------------------------------------------------- - name: Download portable artifact uses: actions/download-artifact@v8 with: name: ${{ matrix.portable_artifact }} path: artifact\files # workflow_run => use the triggering run's ID # workflow_dispatch => use input run_id if provided, otherwise current run run-id: ${{ github.event.workflow_run.id || github.event.inputs.run_id || github.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} repository: ${{ github.repository }} # ---------------------------------------------------------------- # 3. Extract version # Qt6: hardcoded "0.200.1" here — deliberately NOT parsed from the # binary/source, since Qt-version self-detection inside the # compiled code proved unreliable (see commit e1aa65f). The CI # matrix entry already knows for certain which flavor it is # packaging, so the version label is set explicitly at the # packaging level instead. # ---------------------------------------------------------------- - name: Extract version id: version shell: pwsh run: | if ("${{ matrix.version_source }}" -eq "hardcoded") { $ver = "0.200.1" } else { $src = Get-Content "sources\qetversion.cpp" -Raw -ErrorAction SilentlyContinue if ($src -match 'return QVersionNumber\{([^}]+)\}') { $ver = $Matches[1] -replace '\s','' -replace ',','.' } else { # Fallback: CMakeLists.txt $cmake = Get-Content "CMakeLists.txt" -Raw if ($cmake -match 'project\s*\([^)]*VERSION\s+([\d]+\.[\d]+\.[\d]+)') { $ver = $Matches[1] } else { $ver = "0.0.0" } } } # Short SHA for the display version $sha = git rev-parse --short HEAD 2>$null if (-not $sha) { $sha = "unknown" } # Cumulative revision number (same calculation as windows-build.yml) $count = git rev-list HEAD --count 2>$null $rev = [int]$count + 473 # MSI Version only compares Major.Minor.Build (the 4th field is # ignored by Windows Installer for upgrade detection), so we # inject rev into Build to force a real increase between # nightlies and trigger MajorUpgrade's automatic uninstall. $verParts = $ver -split '\.' $verMsi = "$($verParts[0]).$($verParts[1]).$($rev % 65535)" $flavorTag = "${{ matrix.label_suffix }}" $verDisplay = "${ver}${flavorTag}-r${rev}-${sha}_x86_64-win64" echo "VERSION_MSI=$verMsi" >> $env:GITHUB_OUTPUT echo "VERSION_DISPLAY=$verDisplay" >> $env:GITHUB_OUTPUT Write-Host "Version MSI : $verMsi" Write-Host "Version display : $verDisplay" # Qt platform argument: kept from the Qt5 era (QTBUG-83161 # font-rendering workaround, GDI backend). Qt6 uses DirectWrite by # default and does not need it, so this always resolves empty now. if ("${{ matrix.flavor }}" -eq "qt5") { $qtArgs = "-platform windows:fontengine=freetype" } else { $qtArgs = "" } echo "QT_ARGS=$qtArgs" >> $env:GITHUB_OUTPUT Write-Host "Qt platform args: '$qtArgs'" # ---------------------------------------------------------------- # 4. Install WiX v7, accept EULA and install WixUI extension # ---------------------------------------------------------------- - name: Install WiX v7 shell: pwsh run: | dotnet tool install --global wix --version 7.0.0 $toolsPath = [System.IO.Path]::Combine($env:USERPROFILE, '.dotnet', 'tools') $env:PATH = "$toolsPath;$env:PATH" echo $toolsPath >> $env:GITHUB_PATH wix eula accept wix7 wix extension add WixToolset.UI.wixext/7.0.0 wix extension add WixToolset.Util.wixext/7.0.0 Write-Host "WiX v7 installed, EULA accepted, UI + Util extensions added." # ---------------------------------------------------------------- # 5. Check that the WXS file exists in the repository # ---------------------------------------------------------------- - name: Check WXS file shell: pwsh run: | $wxs = "build-aux\windows\QElectroTech.wxs" if (-not (Test-Path $wxs)) { Write-Error "WXS file not found: $wxs" exit 1 } Write-Host "WXS found: $wxs" # ---------------------------------------------------------------- # 6. Check the artifact structure and locate files/ # ---------------------------------------------------------------- - name: Check artifact structure shell: pwsh run: | $exe = Get-ChildItem -Path "artifact\files" -Filter "qelectrotech.exe" -Recurse | Select-Object -First 1 if (-not $exe) { $exe = Get-ChildItem -Path "artifact\files" -Filter "QElectroTech.exe" -Recurse | Select-Object -First 1 } if (-not $exe) { Write-Error "qelectrotech.exe not found in artifact" exit 1 } Write-Host "Executable: $($exe.FullName) ($([math]::Round($exe.Length/1MB,1)) MB)" $binDir = $exe.Directory.FullName $filesDir = Split-Path $binDir -Parent echo "FILES_DIR=$filesDir" >> $env:GITHUB_ENV Write-Host "FILES_DIR: $filesDir" # ---------------------------------------------------------------- # 7. Convert LICENSE (GPL-2) to RTF for the WixUI licence screen # ---------------------------------------------------------------- - name: Convert LICENSE to RTF shell: pwsh run: | $licSrc = "LICENSE" $licRtf = "$env:TEMP\License.rtf" if (-not (Test-Path $licSrc)) { Write-Error "LICENSE file not found in repository root" exit 1 } $lines = Get-Content $licSrc -Encoding UTF8 $rtf = New-Object System.Text.StringBuilder [void]$rtf.AppendLine('{\rtf1\ansi\ansicpg1252\deff0') [void]$rtf.AppendLine('{\fonttbl{\f0\fmodern\fprq1\fcharset0 Courier New;}}') [void]$rtf.AppendLine('{\colortbl;\red0\green0\blue0;}') [void]$rtf.AppendLine('\f0\fs18\cf1') foreach ($line in $lines) { $escaped = $line ` -replace '\\', '\\\\' ` -replace '\{', '\{' ` -replace '\}', '\}' [void]$rtf.AppendLine("$escaped\par") } [void]$rtf.AppendLine('}') [System.IO.File]::WriteAllText($licRtf, $rtf.ToString(), [System.Text.Encoding]::ASCII) echo "LICENSE_RTF=$licRtf" >> $env:GITHUB_ENV Write-Host "License.rtf generated: $licRtf" # ---------------------------------------------------------------- # 8. Replace Lancer QET.bat with the MSI-specific version # ---------------------------------------------------------------- - name: Replace Lancer QET.bat for MSI shell: pwsh run: | $qtArgs = "${{ steps.version.outputs.QT_ARGS }}" $bat = "$env:FILES_DIR\Lancer QET.bat" $content = "@echo off`r`nstart `"`" `"%~dp0bin\qelectrotech.exe`" --common-elements-dir=`"%~dp0elements/`" --common-tbt-dir=`"%~dp0titleblocks/`" --lang-dir=`"%~dp0lang/`" $qtArgs`r`n" [System.IO.File]::WriteAllText($bat, $content, [System.Text.Encoding]::ASCII) Write-Host "Lancer QET.bat replaced for MSI installation (qtArgs: '$qtArgs')." # ---------------------------------------------------------------- # 9. Build the MSI (unsigned at this stage — signing happens below) # Qt6 keeps its own ProductCode seed (distinct from the retired Qt5 # MSI), so a machine that still has the old Qt5 MSI installed gets a # separate, coexisting install rather than an unexpected upgrade. # ---------------------------------------------------------------- - name: Build MSI shell: pwsh run: | $version = "${{ steps.version.outputs.VERSION_MSI }}" $verDisplay = "${{ steps.version.outputs.VERSION_DISPLAY }}" $filesDir = $env:FILES_DIR $licRtf = $env:LICENSE_RTF $wxs = "build-aux\windows\QElectroTech.wxs" $outputName = "QElectroTech-${verDisplay}.msi" $seed = "qelectrotech-msi-${{ matrix.flavor }}-$version" $sha1 = [System.Security.Cryptography.SHA1]::Create() $hash = $sha1.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($seed)) $hash[6] = ($hash[6] -band 0x0F) -bor 0x50 $hash[8] = ($hash[8] -band 0x3F) -bor 0x80 $productCode = "{$([System.Guid]::new([byte[]]$hash[0..15]).ToString().ToUpper())}" New-Item -ItemType Directory -Force -Path "dist" | Out-Null Write-Host "=== wix build (${{ matrix.flavor }}) ===" Write-Host " Version : $version" Write-Host " ProductCode : $productCode" Write-Host " Output : dist\$outputName" $qtArgs = "${{ steps.version.outputs.QT_ARGS }}" wix build $wxs ` -arch x64 ` -d "Version=$version" ` -d "ProductVersion=$verDisplay" ` -d "ProductCode=$productCode" ` -d "FilesDir=$filesDir" ` -d "LicenseRtf=$licRtf" ` -d QtPlatformArgs="$qtArgs" ` -ext WixToolset.UI.wixext ` -ext WixToolset.Util.wixext ` -o "dist\$outputName" if (-not (Test-Path "dist\$outputName")) { Write-Error "MSI not generated: dist\$outputName" exit 1 } $size = [math]::Round((Get-Item "dist\$outputName").Length / 1MB, 1) Write-Host "MSI generated: dist\$outputName ($size MB) OK" echo "MSI_NAME=$outputName" >> $env:GITHUB_ENV - name: Upload unsigned MSI artifact (pre-signing) id: upload_unsigned uses: actions/upload-artifact@v7 with: name: qelectrotech-windows-msi-unsigned-${{ matrix.flavor }} path: dist\*.msi retention-days: 1 if-no-files-found: error # Qt6 is now signed too (previously excluded while Qt5 was the stable # track and Qt6 was experimental-only). The remaining guard is the fork # check: forks never have the SignPath secrets, and a fork-originated # PR/run must never attempt a signing request. # (cf. DieterMayerOSS:fix/msi-signing-fork-guard, d3f60c88) # continue-on-error: SignPath's certificate is still pending validation # (Sept 2026) — the signing request currently fails with a 500 on the # SignPath side. Kept non-blocking so the nightly MSI still ships # (unsigned) while the certificate is pending. Remove # continue-on-error once the certificate is confirmed active. - name: Sign MSI via SignPath id: sign if: github.repository == 'qelectrotech/qelectrotech-source-mirror' && env.SIGNPATH_API_TOKEN != '' continue-on-error: true uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} project-slug: 'qelectrotech-source-mirror' signing-policy-slug: 'test-signing' artifact-configuration-slug: 'MSI' github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: 'dist\' # If signing succeeded, SignPath already overwrote dist\*.msi with the # signed copy (output-artifact-directory above) — nothing to do here. # If it failed/was skipped, dist\*.msi is still the unsigned MSI from # the "Build MSI" step, so the rest of the pipeline just ships that. - name: Report signing status if: always() shell: pwsh run: | if ("${{ steps.sign.outcome }}" -eq "success") { Write-Host "MSI signed successfully via SignPath." } else { Write-Warning "MSI signing skipped or failed (outcome: ${{ steps.sign.outcome }}) — shipping UNSIGNED MSI. Likely cause: SignPath certificate still pending validation." } - name: Upload signed MSI artifact if: always() uses: actions/upload-artifact@v7 with: name: qelectrotech-windows-msi-${{ matrix.flavor }} path: dist\*.msi retention-days: 40 if-no-files-found: error - name: Delete old nightly .msi asset if: always() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | $pattern = "-qt6.*\\.msi$" $names = gh release view nightly --repo $env:REPO --json assets --jq ".assets[] | select(.name | test(`"$pattern`")) | .name" foreach ($name in ($names -split "`n" | Where-Object { $_ })) { Write-Host "Deleting old asset: $name" gh release delete-asset nightly $name --repo $env:REPO --yes } shell: pwsh - name: Upload MSI to nightly release if: always() uses: softprops/action-gh-release@v3 with: tag_name: nightly files: dist/*.msi fail_on_unmatched_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Export MSI name for page generation id: export shell: pwsh run: | $name = "$env:MSI_NAME" echo "msi_name_qt6=$name" >> $env:GITHUB_OUTPUT - name: Summary if: always() shell: pwsh run: | Write-Host "=== MSI build summary (${{ matrix.flavor }}) ===" Write-Host "Version : ${{ steps.version.outputs.VERSION_DISPLAY }}" Write-Host "Signed : ${{ steps.sign.outcome == 'success' }}" # --------------------------------------------------------------------------- # Job 2 : Génère et déploie la page GitHub Pages une fois le MSI publié, # pour que toutes les URLs soient connues. # --------------------------------------------------------------------------- deploy-pages: needs: build-msi runs-on: ubuntu-latest if: always() && needs.build-msi.result == 'success' permissions: contents: write pages: write id-token: write steps: - name: Checkout master (for generate-page.py) uses: actions/checkout@v7 with: ref: master path: source sparse-checkout: build-aux/generate-page.py sparse-checkout-cone-mode: false - name: Generate download page (index.html) shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail REPO="${{ github.repository }}" ASSETS=$(gh release view nightly --repo "$REPO" --json assets --jq '.assets[].name') EXE_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.exe$' | head -1) ZIP_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.zip$' | head -1) MSI_NAME=$(echo "$ASSETS" | grep 'qt6' | grep '\.msi$' | head -1 || echo "") # Legacy Qt5 assets (no "qt6" in the name): last ever published, # frozen — windows-build.yml/windows-msi.yml no longer delete or # replace these, so they keep pointing at the same files release # after release. Rendered as a separate "legacy" section if present. LEGACY_EXE_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.exe$' | head -1 || echo "") LEGACY_ZIP_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.zip$' | head -1 || echo "") LEGACY_MSI_NAME=$(echo "$ASSETS" | grep -v 'qt6' | grep '\.msi$' | head -1 || echo "") BASE="https://github.com/$REPO/releases/download/nightly" INSTALLER_URL="$BASE/$EXE_NAME" PORTABLE_URL="$BASE/$ZIP_NAME" MSI_URL="" [ -n "$MSI_NAME" ] && MSI_URL="$BASE/$MSI_NAME" LEGACY_INSTALLER_URL="" LEGACY_PORTABLE_URL="" LEGACY_MSI_URL="" [ -n "$LEGACY_EXE_NAME" ] && LEGACY_INSTALLER_URL="$BASE/$LEGACY_EXE_NAME" [ -n "$LEGACY_ZIP_NAME" ] && LEGACY_PORTABLE_URL="$BASE/$LEGACY_ZIP_NAME" [ -n "$LEGACY_MSI_NAME" ] && LEGACY_MSI_URL="$BASE/$LEGACY_MSI_NAME" SHA="${{ github.event.workflow_run.head_sha || github.sha }}" SHORT="${SHA:0:7}" DATE=$(date -u '+%Y-%m-%d %H:%M UTC') RUN_URL="https://github.com/$REPO/actions/runs/${{ github.run_id }}" RUN_NUMBER="${{ github.run_number }}" export DATE SHORT REPO SHA RUN_URL RUN_NUMBER export INSTALLER_URL PORTABLE_URL MSI_URL export LEGACY_INSTALLER_URL LEGACY_PORTABLE_URL LEGACY_MSI_URL python3 source/build-aux/generate-page.py - name: Add .nojekyll shell: bash run: touch gh-pages/.nojekyll - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v5 with: path: gh-pages/ - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4