windows-installer.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. #
  13. # The work is split across two jobs on purpose. Signing requests against the
  14. # Foundation *production* certificate require a human to approve them in the
  15. # SignPath UI, so the submit step can sit waiting for up to an hour. Keeping
  16. # the compile in its own job means that wait doesn't hold the build hostage:
  17. # the unsigned artifact is already uploaded and addressable by id, so a missed
  18. # approval window is recovered by re-running `publish` alone rather than
  19. # rebuilding the installer from scratch.
  20. #
  21. # Note that `publish` runs for unsigned builds too -- it is the single place
  22. # that produces the release-facing artifact, so the daily-prerelease path and
  23. # the signed-tag path share one set of alias/upload/attach steps.
  24. on:
  25. push:
  26. tags:
  27. - 'v*'
  28. workflow_dispatch:
  29. inputs:
  30. sign:
  31. description: 'Submit the installer to SignPath for signing'
  32. type: boolean
  33. default: false
  34. # Least-privilege per CodeQL actions/missing-workflow-permissions.
  35. # contents: write is required by softprops/action-gh-release to attach
  36. # the .exe to a tag release; the manual-dispatch path doesn't trigger
  37. # that step and could run with read-only, but a single workflow-level
  38. # block keeps the surface auditable in one place.
  39. # actions: read lets the SignPath connector download the uploaded artifact
  40. # through the API (and lets download-artifact do the same in `publish`). Declaring
  41. # a permissions block at all drops every scope we don't name to `none`, so the
  42. # signing step fails to fetch the artifact without it.
  43. permissions:
  44. contents: write
  45. actions: read
  46. jobs:
  47. build:
  48. runs-on: windows-latest
  49. timeout-minutes: 30
  50. outputs:
  51. # The artifact id is how SignPath addresses the thing to sign; see the
  52. # upload step below for why that indirection exists.
  53. artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
  54. sign: ${{ steps.decide.outputs.sign }}
  55. steps:
  56. - name: Checkout
  57. uses: actions/checkout@v4
  58. # Sign real release tags but not `-daily.` prereleases, and let a manual
  59. # run opt in. GitHub's `||` returns the *last* operand when everything is
  60. # falsy (an empty string here, not `false`), so this normalises the answer
  61. # to the literal strings 'true'/'false' once, in one place, and every use
  62. # site compares against 'true' rather than treating it as a boolean.
  63. # Echoing the decision makes "why wasn't my tag signed?" answerable from
  64. # the run log alone.
  65. - name: Decide whether this build gets signed
  66. id: decide
  67. shell: bash
  68. run: |
  69. if [ "${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}" = "true" ]; then
  70. echo "sign=true" >> "$GITHUB_OUTPUT"
  71. echo "signing ENABLED for $GITHUB_REF"
  72. else
  73. echo "sign=false" >> "$GITHUB_OUTPUT"
  74. echo "signing SKIPPED for $GITHUB_REF"
  75. fi
  76. - name: Setup Python
  77. uses: actions/setup-python@v5
  78. with:
  79. python-version: '3.13'
  80. - name: Setup Node.js
  81. uses: actions/setup-node@v4
  82. with:
  83. node-version: '22'
  84. # Inno Setup 6.x is pre-installed on windows-latest runners (under
  85. # C:\Program Files (x86)\Inno Setup 6\). No install step needed.
  86. - name: Stage installer artifacts
  87. working-directory: installers/windows
  88. run: python build.py
  89. shell: pwsh
  90. - name: Compile installer (ISCC)
  91. working-directory: installers/windows
  92. run: |
  93. & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
  94. shell: pwsh
  95. # SignPath signs a *GitHub artifact*, not a workspace path: the connector
  96. # pulls the artifact back out through the API, which is why this upload
  97. # has to happen before signing and why upload-artifact must be v4 or newer
  98. # (older versions expose no `artifact-id` output). Kept as a separate,
  99. # clearly-named artifact so an unsigned build is never mistaken for a
  100. # signed one when downloading from the run page.
  101. - name: Upload unsigned installer
  102. id: upload_unsigned
  103. uses: actions/upload-artifact@v7
  104. with:
  105. name: bambuddy-windows-installer-unsigned
  106. path: installers/windows/build/output/*.exe
  107. if-no-files-found: error
  108. # Only needed while SignPath fetches it during this run; the default
  109. # 90 days of every daily build filled the account's Actions storage.
  110. retention-days: 1
  111. publish:
  112. needs: build
  113. runs-on: windows-latest
  114. # Sized to sit outside the signing wait below: an hour for a human to
  115. # approve the request in the SignPath UI, plus headroom for the download,
  116. # verification and upload either side of it.
  117. timeout-minutes: 70
  118. env:
  119. SIGN: ${{ needs.build.outputs.sign }}
  120. steps:
  121. # Rehydrate the compiled installer into the same path the build job used,
  122. # so every step below is identical whether or not signing ran. No checkout
  123. # is needed here -- nothing in this job reads the repository.
  124. - name: Download unsigned installer
  125. uses: actions/download-artifact@v7
  126. with:
  127. name: bambuddy-windows-installer-unsigned
  128. path: installers/windows/build/output
  129. # The artifact arrives at SignPath as a .zip (that is simply what
  130. # upload-artifact produces), so the artifact configuration on the SignPath
  131. # side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
  132. # left at its default the signed archive is extracted again here, so
  133. # `signed/` ends up holding the bare .exe.
  134. #
  135. # The wait timeout is explicit because the action defaults to 600s, which
  136. # is fine for the auto-approved test policy but far too short once a human
  137. # has to approve each production request by hand.
  138. - name: Sign installer (SignPath)
  139. if: env.SIGN == 'true'
  140. uses: signpath/github-action-submit-signing-request@v2
  141. with:
  142. api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
  143. # Not a credential -- the organization ID appears in ordinary SignPath
  144. # URLs and is useless without the API token above.
  145. organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
  146. project-slug: 'bambuddy'
  147. signing-policy-slug: 'test-signing'
  148. github-artifact-id: ${{ needs.build.outputs.artifact-id }}
  149. wait-for-completion: true
  150. wait-for-completion-timeout-in-seconds: '3600'
  151. output-artifact-directory: installers/windows/build/signed
  152. # Replace the unsigned binary in-place so every downstream step (alias,
  153. # artifact upload, release attachment) keeps working off one directory and
  154. # cannot accidentally publish the unsigned copy.
  155. - name: Promote signed installer
  156. if: env.SIGN == 'true'
  157. shell: pwsh
  158. working-directory: installers/windows/build
  159. run: |
  160. $signed = @(Get-ChildItem -Path signed -Filter *.exe)
  161. if ($signed.Count -ne 1) {
  162. throw "expected exactly one signed .exe, found $($signed.Count)"
  163. }
  164. Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
  165. Write-Host "promoted signed installer: $($signed[0].Name)"
  166. # Fail loudly rather than shipping an unsigned .exe under a signed
  167. # release. The test certificate is self-signed, so Windows reports the
  168. # signature as untrusted (`UnknownError`) -- that is expected and is not
  169. # what this checks. Only the absence of a signature is treated as a
  170. # failure; swap in a stricter assertion once the production certificate
  171. # is imported.
  172. - name: Verify signature
  173. if: env.SIGN == 'true'
  174. shell: pwsh
  175. working-directory: installers/windows/build/output
  176. run: |
  177. Get-ChildItem -Filter *.exe | ForEach-Object {
  178. $sig = Get-AuthenticodeSignature $_.FullName
  179. if ($sig.Status -eq 'NotSigned') {
  180. throw "$($_.Name) carries no Authenticode signature"
  181. }
  182. Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
  183. }
  184. # Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
  185. # copy alongside the versioned filename so external surfaces (website,
  186. # wiki, newsletters) can link to a stable URL that survives version
  187. # bumps:
  188. #
  189. # https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe
  190. #
  191. # GitHub's `latest` redirect excludes prereleases, so this URL always
  192. # points at whatever was released as a full release. Daily prereleases
  193. # are excluded from the alias because (a) the unversioned name would be
  194. # semantically confusing next to the date-stamped versioned name on a
  195. # daily prerelease page, and (b) there's no stable "latest daily" URL
  196. # anyway (`latest` skips prereleases), so the alias adds no value there.
  197. #
  198. # Runs after signing so the alias is a copy of the *signed* binary.
  199. #
  200. # This and the two steps after it carry no `always()`/`failure()`, so
  201. # GitHub ANDs an implicit `success()` into each. A failed or timed-out
  202. # signing therefore skips the alias, the artifact upload and the release
  203. # attachment -- an unsigned .exe cannot reach a release. Preserve that
  204. # property through any future edit.
  205. - name: Create unversioned alias (non-daily tags only)
  206. if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
  207. shell: pwsh
  208. working-directory: installers/windows/build/output
  209. run: |
  210. $versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1
  211. if (-not $versioned) { throw "no versioned installer .exe found" }
  212. Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe"
  213. Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
  214. - name: Upload installer artifact
  215. uses: actions/upload-artifact@v7
  216. with:
  217. name: bambuddy-windows-installer
  218. path: installers/windows/build/output/*.exe
  219. if-no-files-found: error
  220. # Release tags attach the installer to the release below; the run
  221. # copy is only for downloading a manual or daily build.
  222. retention-days: 7
  223. - name: Attach installer to release
  224. if: startsWith(github.ref, 'refs/tags/v')
  225. uses: softprops/action-gh-release@v2
  226. with:
  227. files: installers/windows/build/output/*.exe
  228. fail_on_unmatched_files: true