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.

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

      - 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

      # 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 <zip-file> wrapping the <pe-file>. 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
      # 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.
      - 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
