diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 6d1036783..682118c1e 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -6,29 +6,47 @@ name: Windows Installer # - Tag push matching v* (release builds, uploaded as a release asset) # - Manual dispatch (for testing the build pipeline) # -# The installer is unsigned until SignPath OSS approval lands. Once it -# does, add the SignPath GitHub Action between the ISCC step and the -# upload step. +# 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. 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. 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 + env: + # 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 every use site compares + # against the string 'true' rather than treating this as a boolean. + SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }} + steps: - name: Checkout uses: actions/checkout@v4 @@ -57,6 +75,73 @@ jobs: & "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 + + # 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. + - 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: ${{ steps.upload_unsigned.outputs.artifact-id }} + wait-for-completion: true + 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 @@ -70,6 +155,8 @@ jobs: # 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. - name: Create unversioned alias (non-daily tags only) if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.') shell: pwsh @@ -81,7 +168,7 @@ jobs: Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)" - name: Upload installer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bambuddy-windows-installer path: installers/windows/build/output/*.exe