name: Windows Installer # Build the Windows installer .exe. # # Triggers: # - Tag push matching v* (release builds, uploaded as a release asset) # - Manual dispatch (for testing the build pipeline) # # Release tags are Authenticode-signed through the SignPath Foundation OSS # program. Daily prereleases are deliberately left unsigned so they don't burn # the OSS signing quota; use the `sign` dispatch input to exercise the signing # path by hand. # # The work is split across two jobs on purpose. Signing requests against the # Foundation *production* certificate require a human to approve them in the # SignPath UI, so the submit step can sit waiting for up to an hour. Keeping # the compile in its own job means that wait doesn't hold the build hostage: # the unsigned artifact is already uploaded and addressable by id, so a missed # approval window is recovered by re-running `publish` alone rather than # rebuilding the installer from scratch. # # Note that `publish` runs for unsigned builds too -- it is the single place # that produces the release-facing artifact, so the daily-prerelease path and # the signed-tag path share one set of alias/upload/attach steps. on: push: tags: - 'v*' workflow_dispatch: inputs: sign: description: 'Submit the installer to SignPath for signing' type: boolean default: false # Least-privilege per CodeQL actions/missing-workflow-permissions. # contents: write is required by softprops/action-gh-release to attach # the .exe to a tag release; the manual-dispatch path doesn't trigger # that step and could run with read-only, but a single workflow-level # block keeps the surface auditable in one place. # actions: read lets the SignPath connector download the uploaded artifact # through the API (and lets download-artifact do the same in `publish`). Declaring # a permissions block at all drops every scope we don't name to `none`, so the # signing step fails to fetch the artifact without it. permissions: contents: write actions: read jobs: build: runs-on: windows-latest timeout-minutes: 30 outputs: # The artifact id is how SignPath addresses the thing to sign; see the # upload step below for why that indirection exists. artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }} sign: ${{ steps.decide.outputs.sign }} steps: - name: Checkout uses: actions/checkout@v4 # Sign real release tags but not `-daily.` prereleases, and let a manual # run opt in. GitHub's `||` returns the *last* operand when everything is # falsy (an empty string here, not `false`), so this normalises the answer # to the literal strings 'true'/'false' once, in one place, and every use # site compares against 'true' rather than treating it as a boolean. # Echoing the decision makes "why wasn't my tag signed?" answerable from # the run log alone. - name: Decide whether this build gets signed id: decide shell: bash run: | if [ "${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}" = "true" ]; then echo "sign=true" >> "$GITHUB_OUTPUT" echo "signing ENABLED for $GITHUB_REF" else echo "sign=false" >> "$GITHUB_OUTPUT" echo "signing SKIPPED for $GITHUB_REF" fi - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' # Inno Setup 6.x is pre-installed on windows-latest runners (under # C:\Program Files (x86)\Inno Setup 6\). No install step needed. - name: Stage installer artifacts working-directory: installers/windows run: python build.py shell: pwsh - name: Compile installer (ISCC) working-directory: installers/windows run: | & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss shell: pwsh # SignPath signs a *GitHub artifact*, not a workspace path: the connector # pulls the artifact back out through the API, which is why this upload # has to happen before signing and why upload-artifact must be v4 or newer # (older versions expose no `artifact-id` output). Kept as a separate, # clearly-named artifact so an unsigned build is never mistaken for a # signed one when downloading from the run page. - name: Upload unsigned installer id: upload_unsigned uses: actions/upload-artifact@v7 with: name: bambuddy-windows-installer-unsigned path: installers/windows/build/output/*.exe if-no-files-found: error publish: needs: build runs-on: windows-latest # Sized to sit outside the signing wait below: an hour for a human to # approve the request in the SignPath UI, plus headroom for the download, # verification and upload either side of it. timeout-minutes: 70 env: SIGN: ${{ needs.build.outputs.sign }} steps: # Rehydrate the compiled installer into the same path the build job used, # so every step below is identical whether or not signing ran. No checkout # is needed here -- nothing in this job reads the repository. - name: Download unsigned installer uses: actions/download-artifact@v7 with: name: bambuddy-windows-installer-unsigned path: installers/windows/build/output # The artifact arrives at SignPath as a .zip (that is simply what # upload-artifact produces), so the artifact configuration on the SignPath # side describes a wrapping the . With skip-decompress # left at its default the signed archive is extracted again here, so # `signed/` ends up holding the bare .exe. # # The wait timeout is explicit because the action defaults to 600s, which # is fine for the auto-approved test policy but far too short once a human # has to approve each production request by hand. - name: Sign installer (SignPath) if: env.SIGN == 'true' uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} # Not a credential -- the organization ID appears in ordinary SignPath # URLs and is useless without the API token above. organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386' project-slug: 'bambuddy' signing-policy-slug: 'test-signing' github-artifact-id: ${{ needs.build.outputs.artifact-id }} wait-for-completion: true wait-for-completion-timeout-in-seconds: '3600' output-artifact-directory: installers/windows/build/signed # Replace the unsigned binary in-place so every downstream step (alias, # artifact upload, release attachment) keeps working off one directory and # cannot accidentally publish the unsigned copy. - name: Promote signed installer if: env.SIGN == 'true' shell: pwsh working-directory: installers/windows/build run: | $signed = @(Get-ChildItem -Path signed -Filter *.exe) if ($signed.Count -ne 1) { throw "expected exactly one signed .exe, found $($signed.Count)" } Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name) Write-Host "promoted signed installer: $($signed[0].Name)" # Fail loudly rather than shipping an unsigned .exe under a signed # release. The test certificate is self-signed, so Windows reports the # signature as untrusted (`UnknownError`) -- that is expected and is not # what this checks. Only the absence of a signature is treated as a # failure; swap in a stricter assertion once the production certificate # is imported. - name: Verify signature if: env.SIGN == 'true' shell: pwsh working-directory: installers/windows/build/output run: | Get-ChildItem -Filter *.exe | ForEach-Object { $sig = Get-AuthenticodeSignature $_.FullName if ($sig.Status -eq 'NotSigned') { throw "$($_.Name) carries no Authenticode signature" } Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)" } # Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned # copy alongside the versioned filename so external surfaces (website, # wiki, newsletters) can link to a stable URL that survives version # bumps: # # https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe # # GitHub's `latest` redirect excludes prereleases, so this URL always # points at whatever was released as a full release. Daily prereleases # are excluded from the alias because (a) the unversioned name would be # semantically confusing next to the date-stamped versioned name on a # daily prerelease page, and (b) there's no stable "latest daily" URL # anyway (`latest` skips prereleases), so the alias adds no value there. # # Runs after signing so the alias is a copy of the *signed* binary. # # This and the two steps after it carry no `always()`/`failure()`, so # GitHub ANDs an implicit `success()` into each. A failed or timed-out # signing therefore skips the alias, the artifact upload and the release # attachment -- an unsigned .exe cannot reach a release. Preserve that # property through any future edit. - name: Create unversioned alias (non-daily tags only) if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.') shell: pwsh working-directory: installers/windows/build/output run: | $versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1 if (-not $versioned) { throw "no versioned installer .exe found" } Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe" Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)" - name: Upload installer artifact uses: actions/upload-artifact@v7 with: name: bambuddy-windows-installer path: installers/windows/build/output/*.exe if-no-files-found: error - name: Attach installer to release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: files: installers/windows/build/output/*.exe fail_on_unmatched_files: true