windows-installer.yml 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. name: Windows Installer
  2. # Build the Windows installer .exe.
  3. #
  4. # Triggers:
  5. # - Tag push matching v* (release builds, uploaded as a release asset)
  6. # - Manual dispatch (for testing the build pipeline)
  7. #
  8. # Release tags are Authenticode-signed through the SignPath Foundation OSS
  9. # program. Daily prereleases are deliberately left unsigned so they don't burn
  10. # the OSS signing quota; use the `sign` dispatch input to exercise the signing
  11. # path by hand.
  12. on:
  13. push:
  14. tags:
  15. - 'v*'
  16. workflow_dispatch:
  17. inputs:
  18. sign:
  19. description: 'Submit the installer to SignPath for signing'
  20. type: boolean
  21. default: false
  22. # Least-privilege per CodeQL actions/missing-workflow-permissions.
  23. # contents: write is required by softprops/action-gh-release to attach
  24. # the .exe to a tag release; the manual-dispatch path doesn't trigger
  25. # that step and could run with read-only, but a single workflow-level
  26. # block keeps the surface auditable in one place.
  27. # actions: read lets the SignPath connector download the uploaded artifact
  28. # through the API. Declaring a permissions block at all drops every scope we
  29. # don't name to `none`, so the signing step fails to fetch the artifact
  30. # without it.
  31. permissions:
  32. contents: write
  33. actions: read
  34. jobs:
  35. build:
  36. runs-on: windows-latest
  37. timeout-minutes: 30
  38. env:
  39. # Sign real release tags but not `-daily.` prereleases, and let a manual
  40. # run opt in. GitHub's `||` returns the *last* operand when everything is
  41. # falsy (an empty string here, not `false`), so every use site compares
  42. # against the string 'true' rather than treating this as a boolean.
  43. SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}
  44. steps:
  45. - name: Checkout
  46. uses: actions/checkout@v4
  47. - name: Setup Python
  48. uses: actions/setup-python@v5
  49. with:
  50. python-version: '3.13'
  51. - name: Setup Node.js
  52. uses: actions/setup-node@v4
  53. with:
  54. node-version: '22'
  55. # Inno Setup 6.x is pre-installed on windows-latest runners (under
  56. # C:\Program Files (x86)\Inno Setup 6\). No install step needed.
  57. - name: Stage installer artifacts
  58. working-directory: installers/windows
  59. run: python build.py
  60. shell: pwsh
  61. - name: Compile installer (ISCC)
  62. working-directory: installers/windows
  63. run: |
  64. & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
  65. shell: pwsh
  66. # SignPath signs a *GitHub artifact*, not a workspace path: the connector
  67. # pulls the artifact back out through the API, which is why this upload
  68. # has to happen before signing and why upload-artifact must be v4 or newer
  69. # (older versions expose no `artifact-id` output). Kept as a separate,
  70. # clearly-named artifact so an unsigned build is never mistaken for a
  71. # signed one when downloading from the run page.
  72. - name: Upload unsigned installer
  73. id: upload_unsigned
  74. uses: actions/upload-artifact@v7
  75. with:
  76. name: bambuddy-windows-installer-unsigned
  77. path: installers/windows/build/output/*.exe
  78. if-no-files-found: error
  79. # The artifact arrives at SignPath as a .zip (that is simply what
  80. # upload-artifact produces), so the artifact configuration on the SignPath
  81. # side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
  82. # left at its default the signed archive is extracted again here, so
  83. # `signed/` ends up holding the bare .exe.
  84. - name: Sign installer (SignPath)
  85. if: env.SIGN == 'true'
  86. uses: signpath/github-action-submit-signing-request@v2
  87. with:
  88. api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
  89. # Not a credential -- the organization ID appears in ordinary SignPath
  90. # URLs and is useless without the API token above.
  91. organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
  92. project-slug: 'bambuddy'
  93. signing-policy-slug: 'test-signing'
  94. github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
  95. wait-for-completion: true
  96. output-artifact-directory: installers/windows/build/signed
  97. # Replace the unsigned binary in-place so every downstream step (alias,
  98. # artifact upload, release attachment) keeps working off one directory and
  99. # cannot accidentally publish the unsigned copy.
  100. - name: Promote signed installer
  101. if: env.SIGN == 'true'
  102. shell: pwsh
  103. working-directory: installers/windows/build
  104. run: |
  105. $signed = @(Get-ChildItem -Path signed -Filter *.exe)
  106. if ($signed.Count -ne 1) {
  107. throw "expected exactly one signed .exe, found $($signed.Count)"
  108. }
  109. Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
  110. Write-Host "promoted signed installer: $($signed[0].Name)"
  111. # Fail loudly rather than shipping an unsigned .exe under a signed
  112. # release. The test certificate is self-signed, so Windows reports the
  113. # signature as untrusted (`UnknownError`) -- that is expected and is not
  114. # what this checks. Only the absence of a signature is treated as a
  115. # failure; swap in a stricter assertion once the production certificate
  116. # is imported.
  117. - name: Verify signature
  118. if: env.SIGN == 'true'
  119. shell: pwsh
  120. working-directory: installers/windows/build/output
  121. run: |
  122. Get-ChildItem -Filter *.exe | ForEach-Object {
  123. $sig = Get-AuthenticodeSignature $_.FullName
  124. if ($sig.Status -eq 'NotSigned') {
  125. throw "$($_.Name) carries no Authenticode signature"
  126. }
  127. Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
  128. }
  129. # Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
  130. # copy alongside the versioned filename so external surfaces (website,
  131. # wiki, newsletters) can link to a stable URL that survives version
  132. # bumps:
  133. #
  134. # https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe
  135. #
  136. # GitHub's `latest` redirect excludes prereleases, so this URL always
  137. # points at whatever was released as a full release. Daily prereleases
  138. # are excluded from the alias because (a) the unversioned name would be
  139. # semantically confusing next to the date-stamped versioned name on a
  140. # daily prerelease page, and (b) there's no stable "latest daily" URL
  141. # anyway (`latest` skips prereleases), so the alias adds no value there.
  142. #
  143. # Runs after signing so the alias is a copy of the *signed* binary.
  144. - name: Create unversioned alias (non-daily tags only)
  145. if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
  146. shell: pwsh
  147. working-directory: installers/windows/build/output
  148. run: |
  149. $versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1
  150. if (-not $versioned) { throw "no versioned installer .exe found" }
  151. Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe"
  152. Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
  153. - name: Upload installer artifact
  154. uses: actions/upload-artifact@v7
  155. with:
  156. name: bambuddy-windows-installer
  157. path: installers/windows/build/output/*.exe
  158. if-no-files-found: error
  159. - name: Attach installer to release
  160. if: startsWith(github.ref, 'refs/tags/v')
  161. uses: softprops/action-gh-release@v2
  162. with:
  163. files: installers/windows/build/output/*.exe
  164. fail_on_unmatched_files: true