name: Build

on:
  push:
    branches: [main]
    tags: ['v*']
  # PRs do not build automatically — every commit during review would
  # otherwise re-run the full 3-platform matrix. Trigger the matrix build
  # manually (Actions tab → Build → "Run workflow", pick the PR branch)
  # once the review round is finished.
  # Note: workflow_dispatch only allows selecting branches that exist in
  # this repository; it cannot target branches from contributor forks.
  # For fork PRs, the maintainer must merge to a local branch first.
  #
  # The slopsmith_ref input selects which slopsmith *core* ref to bundle.
  # It defaults to `main` (matching the historical behaviour), but for a
  # coherent release build the desktop release/vX.Y.Z branch should be
  # built against the matching core branch — e.g. run this workflow on
  # release/v0.3.0 with slopsmith_ref=release/v0.3.0.
  workflow_dispatch:
    inputs:
      slopsmith_ref:
        description: "Slopsmith core ref (branch or tag) to bundle, e.g. release/v0.3.0. Defaults to main."
        required: false
        default: main

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-22.04
            platform: linux
            arch: x64
          - os: macos-14
            platform: mac
            arch: arm64
          - os: windows-latest
            platform: win
            arch: x64

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      # Read build configuration to get version numbers for setup tools
      - name: Read build configuration
        id: config
        shell: bash
        run: |
          NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
          PYTHON_VERSION=$(node -p "require('./.build-config.json').versions.python")
          DOTNET_VERSION=$(node -p "require('./.build-config.json').versions.dotnet")
          echo "node=$NODE_VERSION" >> $GITHUB_OUTPUT
          echo "python=$PYTHON_VERSION" >> $GITHUB_OUTPUT
          echo "dotnet=$DOTNET_VERSION" >> $GITHUB_OUTPUT
          echo "Node ${NODE_VERSION}, Python ${PYTHON_VERSION}, .NET ${DOTNET_VERSION}"

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ steps.config.outputs.node }}

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ steps.config.outputs.python }}

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ steps.config.outputs.dotnet }}.x

      # macOS only: import the Developer ID Application certificate into a
      # temporary keychain so codesign + electron-builder + notarytool can
      # find it. The keychain is wiped at the end of the job (see "Clean
      # up signing keychain" below) so a re-run of the same job doesn't
      # see stale state. The whole step is a no-op when the secret is
      # absent — supports forks and PRs from contributors who can't
      # access repository secrets.
      # Step is unconditional on macOS so we always enter the shell, then
      # bail out at runtime if the cert secret isn't present (forks /
      # contributor PRs without secret access, etc.). step-level env: is
      # NOT available in step-level if: expressions, and `secrets.*` is
      # also not available there — runtime check is the only robust way
      # to gate this on secret presence.
      - name: Import Apple signing certificate
        if: matrix.platform == 'mac'
        env:
          APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          set -euo pipefail
          if [[ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ]]; then
              echo "APPLE_CERTIFICATE_P12_BASE64 not set — skipping (unsigned build)"
              exit 0
          fi
          # printf '%s' rather than echo — echo would append a trailing
          # newline that some toolchains accept and some reject when
          # piped into base64 --decode, producing a subtly corrupted
          # .p12 that fails security import with the unhelpful error
          # "SecKeychainItemImport: One or more parameters passed to a
          # function were not valid."
          printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
          security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
          security set-keychain-settings -lut 21600 build.keychain
          # Add build.keychain to the user search list — codesign and
          # electron-builder look up identities via the search list, not
          # the default-keychain pointer. Without this, identity-by-name
          # lookups fall back to the unmodified login keychain and the
          # imported cert is invisible. Keep login.keychain-db in the
          # list so trust anchors etc. still resolve.
          security list-keychains -d user -s build.keychain login.keychain-db
          security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
              -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
          security set-key-partition-list \
              -S apple-tool:,apple:,codesign: \
              -s -k "$KEYCHAIN_PASSWORD" build.keychain
          rm -f "$RUNNER_TEMP/cert.p12"
          # Sanity check — verify the identity is actually present.
          security find-identity -v -p codesigning build.keychain | grep -q "Developer ID Application"

      # macOS only: inline the team ID into package.json so electron-builder's
      # `notarize.teamId` field has a real value (it doesn't expand env vars).
      # Build using the unified platform script
      # All bundling and dependency installation is handled by the script.
      # macOS-only env vars below are consumed by:
      #   APPLE_SIGNING_IDENTITY → sign-macos-binaries.sh (codesign --sign)
      #   APPLE_ID,
      #   APPLE_APP_SPECIFIC_PASSWORD,
      #   APPLE_TEAM_ID          → electron-builder's notarytool path
      # build-macos.sh derives CSC_NAME (which electron-builder uses to
      # pick the signing identity) from APPLE_SIGNING_IDENTITY by
      # stripping the "Developer ID Application:" prefix electron-
      # builder rejects. On non-mac platforms these are empty / unused.
      - name: Build
        run: ./scripts/build-release.sh
        shell: bash
        env:
          # Which slopsmith core ref to clone + bundle. Empty on push/tag
          # events (the inputs context only carries values on
          # workflow_dispatch), so fall back to main — preserving the
          # historical default-branch behaviour. clone_slopsmith() in
          # build-common.sh also defaults to main as a second safety net.
          SLOPSMITH_REF: ${{ inputs.slopsmith_ref || 'main' }}
          # PAT with read access to the private got-feedback repos so
          # build-common.sh can clone core + the bundled plugins.
          GH_CLONE_TOKEN: ${{ secrets.FEEDBACK_CLONE_TOKEN }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          # electron-builder defensively skips code signing when the
          # triggering event is `pull_request` ("forks usually don't have
          # signing secrets"). This workflow no longer runs on the
          # pull_request event — it runs on push and workflow_dispatch —
          # so this env var is currently a no-op. It is kept here as a
          # safety net in case a pull_request trigger is ever re-added:
          # without it the notary service would reject the submission with
          # "signature of the binary is invalid" + "no secure timestamp"
          # + "no hardened runtime" on every nested Electron Helper app.
          CSC_FOR_PULL_REQUEST: "true"

      # Start a debugging session — runs BEFORE keychain cleanup so a
      # signing-related failure can be inspected with the keychain still
      # populated.
      - name: Setup upterm session
        uses: owenthereal/action-upterm@v1
        if: ${{ failure() }}
        with:
          wait-timeout-minutes: 5

      # Tester builds (non-tag only): macOS is packaged for distribution by the
      # Velopack steps below, which run ONLY on tag pushes. On a plain push to
      # main or a manual workflow_dispatch, electron-builder's "dir" target
      # leaves an unpackaged release/mac-*/Slopsmith.app that the artifact
      # upload globs (AppImage/deb/exe/velopack) don't match — so testers got
      # no macOS build. Zip the .app with ditto (preserves the bundle's
      # internal symlinks + any signature/xattrs) so a tester-ready macOS
      # archive ships from ordinary CI runs without cutting a release tag.
      # Skipped on tags so it never races or duplicates the Velopack output.
      # The archive is unsigned/un-notarized — testers clear Gatekeeper with
      #   xattr -dr com.apple.quarantine Slopsmith.app
      - name: Zip macOS app for tester distribution (non-tag)
        if: matrix.platform == 'mac' && !startsWith(github.ref, 'refs/tags/v')
        shell: bash
        run: |
          set -euo pipefail
          app=$(ls -d release/mac*/Slopsmith.app 2>/dev/null | head -n1 || true)
          if [[ -z "${app:-}" || ! -d "$app" ]]; then
            echo "::error::No Slopsmith.app found under release/mac*/ — macOS build produced no bundle."
            exit 1
          fi
          ditto -c -k --sequesterRsrc --keepParent "$app" "release/Slopsmith-macos-arm64.zip"
          echo "Zipped $app -> release/Slopsmith-macos-arm64.zip"
          ls -lh release/Slopsmith-macos-arm64.zip

      # Velopack: derive update channel from tag name. Only runs for
      # tag pushes (refs/tags/v*) on win + mac — Linux still ships via
      # electron-builder AppImage/deb with no auto-update.
      #
      # Channel mapping (plan gotcha #4 — fail on ambiguous tags rather
      # than silently defaulting to stable, which would publish a
      # prerelease into the stable feed):
      #   v1.2.3                 → stable
      #   v1.2.3-alpha.<n>       → alpha
      #   v1.2.3-beta.<n>        → beta
      #   v1.2.3-rc.<n>          → rc
      #   anything else          → job fails
      - name: Derive Velopack channel
        id: velopack_channel
        # Tag builds: derive the release channel for win + mac (linux is
        # electron-builder AppImage/deb, no Velopack). Manual dispatch: also
        # pack a Velopack package for WINDOWS only, on a throwaway `dev`
        # channel. Without this, dispatch (the documented tester-build path)
        # produced no usable Windows artifact — win-unpacked/ isn't uploaded
        # and Velopack only ran on tags, so testers got just .pdb symbols.
        # macOS dispatch stays Velopack-free (it already uploads a tester .zip
        # from the "Zip macOS app" step), so we don't add an unsigned-update
        # mac pack here.
        if: >-
          (startsWith(github.ref, 'refs/tags/v') && matrix.platform != 'linux')
          || (github.event_name == 'workflow_dispatch' && matrix.platform == 'win')
        shell: bash
        run: |
          set -euo pipefail
          # Read tag / event / run number from runtime env vars rather than
          # interpolating GitHub Actions workflow expressions into the script
          # text — a tag containing shell syntax could otherwise execute
          # (template injection).
          if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
            # Strip any prerelease suffix from the app version (0.2.9-beta.1 ->
            # 0.2.9), then tag a unique dev build so successive dispatch builds
            # produce distinct Velopack versions. The `dev` channel keeps these
            # out of the alpha/beta/rc/stable auto-update feeds.
            base="$(node -p "require('./package.json').version")"
            base="${base%%-*}"
            channel=dev
            version="${base}-dev.${GITHUB_RUN_NUMBER}"
            echo "channel=$channel" >> "$GITHUB_OUTPUT"
            echo "version=$version" >> "$GITHUB_OUTPUT"
            echo "Manual dispatch -> channel=$channel version=$version"
            exit 0
          fi
          tag="${GITHUB_REF_NAME}"
          if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-alpha\.[0-9]+$ ]]; then
            channel=alpha
            version="${BASH_REMATCH[1]}-alpha.${tag##*-alpha.}"
          elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-beta\.[0-9]+$ ]]; then
            channel=beta
            version="${BASH_REMATCH[1]}-beta.${tag##*-beta.}"
          elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc\.[0-9]+$ ]]; then
            channel=rc
            version="${BASH_REMATCH[1]}-rc.${tag##*-rc.}"
          elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
            channel=stable
            version="${BASH_REMATCH[1]}"
          else
            echo "::error::Tag '$tag' does not match a known Velopack channel pattern (vX.Y.Z[-{alpha,beta,rc}.N]). Refusing to default to stable."
            exit 1
          fi
          echo "channel=$channel" >> "$GITHUB_OUTPUT"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "Derived channel=$channel version=$version from tag $tag"

      - name: Install Velopack CLI (vpk)
        if: steps.velopack_channel.outputs.channel != ''
        shell: bash
        run: |
          set -euo pipefail
          # Pin vpk to the exact version of the velopack npm SDK used by the
          # app (package.json) — Velopack ships the CLI and SDK in lockstep,
          # and an unpinned install would pull a different latest on each run.
          dotnet tool install -g vpk --version 0.0.1589-ga2c5a97
          echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"

      # Windows: pack the electron-builder unpacked dir into Velopack
      # release assets (per-machine MSI, *-full.nupkg, *-delta.nupkg,
      # releases.<ch>.json). Unsigned for now — see plan gotcha #3.
      #
      # The channel is rid-scoped (win-x64-<track>): Velopack requires a
      # unique channel per os/rid so the win + macOS releases.<ch>.json
      # manifests don't collide when published to one GitHub release.
      # The client's update-manager.ts builds the same name to match.
      #
      # --msi emits a machine-wide MSI bootstrap (validated decision #2 in
      # the integration plan: install to %ProgramFiles%\Slopsmith). The flag
      # is a bool — verified against vpk source at our pinned commit
      # a2c5a97 (src/vpk/Velopack.Vpk/Commands/Packaging/WindowsPackCommand.cs):
      #   AddOption<bool>((v) => BuildMsi = v, "--msi")
      #
      # --instLocation defaults to "Either" (a dual-mode MSI where the user
      # picks at install time). We force PerMachine so the MSI is always
      # machine-wide, matching the plan.
      #
      # vpk also writes a per-user Setup.exe by default — we delete it
      # right after packing so only ONE install method ships. Reasons:
      #   - Two install methods for the same app split the user base in
      #     half for every support question ("where is my app installed?").
      #   - The MSI is what the in-app NSIS migration banner downloads, so
      #     it has to ship anyway.
      # The Velopack feed assets (.nupkg, releases.<channel>.json) drive
      # auto-update for both install methods, so dropping Setup.exe does
      # NOT break auto-update for users who installed via the MSI.
      #
      # Velopack's WindowsVelopackLocator falls back to %LOCALAPPDATA% for
      # the packages staging dir when Program Files isn't writable, so the
      # auto-updater works without elevation. Apply-step elevation is
      # handled by Update.exe's manifest.
      - name: Velopack pack (Windows)
        if: matrix.platform == 'win' && steps.velopack_channel.outputs.channel != ''
        shell: bash
        run: |
          set -euo pipefail
          vpk pack \
            --packId Slopsmith \
            --packVersion "${{ steps.velopack_channel.outputs.version }}" \
            --channel "win-x64-${{ steps.velopack_channel.outputs.channel }}" \
            --packDir release/win-unpacked \
            --msi \
            --instLocation PerMachine \
            -o release/velopack
          # Drop the per-user Setup.exe (default vpk output) so only the
          # per-machine MSI ships. Match permissively — the exact name
          # varies between Velopack versions (Slopsmith-win-Setup.exe vs
          # Slopsmith-win-x64-Setup.exe etc.). nocaseglob so a casing
          # change in vpk's output can't slip an installer past the glob.
          shopt -s nullglob nocaseglob
          removed=0
          for f in release/velopack/*setup.exe; do
            echo "Removing per-user installer: $f"
            rm -f "$f"
            removed=$((removed+1))
          done
          # Enforce the MSI-only policy: hard-fail if any setup installer
          # survived cleanup (e.g. an rm failure) rather than publishing
          # two installer types.
          if compgen -G "release/velopack/*setup.exe" > /dev/null; then
            echo "::error::Setup.exe artifact(s) remain after cleanup; refusing to publish dual installer types."
            ls -1 release/velopack/*setup.exe
            exit 1
          fi
          if [[ "$removed" -eq 0 ]]; then
            # MSI-only is the goal — if vpk emits no Setup.exe at all that
            # is fine, not a failure. Note it in case the naming drifted.
            echo "::notice::No Setup.exe output from vpk; shipping MSI-only artifacts."
          fi
          # Sanity check: the MSI must exist or the release is meaningless.
          # compgen -G, not `ls *.msi` — with nullglob on, an unmatched glob
          # expands to nothing and `ls` would list the cwd and pass silently.
          if ! compgen -G "release/velopack/*.msi" > /dev/null; then
            echo "::error::vpk pack --msi x64 did not produce a .msi file in release/velopack/"
            exit 1
          fi

      # macOS: same, but pass Apple signing identity + notarization
      # creds so Velopack codesigns + notarizes the bundle it generates
      # (otherwise Gatekeeper blocks auto-applied updates — plan
      # gotcha #2). vpk codesigns via --signAppIdentity and notarizes via
      # --notaryProfile, which names a `xcrun notarytool` credential
      # profile — vpk has no raw --apple-id/--password/--team-id flags,
      # so the profile must be created with `notarytool store-credentials`
      # before vpk pack runs.
      # --packDir points directly at the .app bundle: Velopack uses a
      # path ending in .app as a prebuilt bundle. A non-.app folder
      # would instead trigger auto-bundle mode (which also needs
      # --icon). electron-builder's "dir" target writes the bundle to
      # release/mac-arm64/Slopsmith.app on the arm64 runner.
      - name: Velopack pack (macOS)
        if: matrix.platform == 'mac' && steps.velopack_channel.outputs.channel != ''
        shell: bash
        env:
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        run: |
          set -euo pipefail
          # The signed/notarized branch needs ALL four Apple secrets — vpk
          # would fail outright if handed an empty --notary* value. If any
          # is missing (forks, partial secret configs) fall back to an
          # unsigned pack, matching the all-four check in build-macos.sh.
          if [[ -z "${APPLE_SIGNING_IDENTITY:-}" || -z "${APPLE_ID:-}" \
                || -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" \
                || -z "${APPLE_TEAM_ID:-}" ]]; then
            echo "::warning::Apple signing/notarization secrets incomplete — packing macOS Velopack release UNSIGNED. Gatekeeper will block auto-updates on user machines."
            vpk pack \
              --packId Slopsmith \
              --packVersion "${{ steps.velopack_channel.outputs.version }}" \
              --channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
              --packDir release/mac-arm64/Slopsmith.app \
              -o release/velopack
          else
            # vpk notarizes through a notarytool *credential profile*, not
            # raw Apple-ID flags (it shells out to `xcrun notarytool`, which
            # takes --keychain-profile). Create the profile first, then hand
            # vpk --notaryProfile. Do NOT pass --keychain: notarytool's
            # --keychain wants a file *path* (not a name like
            # "build.keychain"), and notarytool stores/reads credential
            # profiles in the login keychain by default. vpk's own internal
            # notarytool call also defaults to the login keychain, so
            # leaving --keychain off keeps both sides pointing at the same
            # store. (build.keychain only holds the signing cert, which
            # codesign finds via the keychain search list.)
            xcrun notarytool store-credentials "velopack-notary" \
              --apple-id "$APPLE_ID" \
              --password "$APPLE_APP_SPECIFIC_PASSWORD" \
              --team-id "$APPLE_TEAM_ID"
            vpk pack \
              --packId Slopsmith \
              --packVersion "${{ steps.velopack_channel.outputs.version }}" \
              --channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
              --packDir release/mac-arm64/Slopsmith.app \
              -o release/velopack \
              --signAppIdentity "$APPLE_SIGNING_IDENTITY" \
              --notaryProfile "velopack-notary"
          fi

      # macOS only: drop the temporary keychain so a re-run doesn't see
      # leftover state. Placed AFTER the Velopack pack steps (which call
      # codesign + notarytool and need the keychain present on tag builds).
      # always() ensures cleanup even on Velopack pack failures.
      - name: Clean up signing keychain
        if: always() && matrix.platform == 'mac'
        run: |
          security delete-keychain build.keychain 2>/dev/null || true

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: slopsmith-${{ matrix.platform }}-${{ matrix.arch }}
          # release/win-unpacked/ is intentionally NOT uploaded: it is the
          # uncompressed intermediate build tree (~1 GB on Windows) that
          # Velopack packs into release/velopack/. Uploading it duplicated
          # the whole app in the artifact and ballooned the Windows artifact
          # ~6x (0.27 -> 1.58 GiB). The packed velopack/ output is all that
          # downstream needs. See issue #185.
          # build/Release/*.pdb: Windows debug symbols for the native addon and
          # the sandbox host. Archived as a workflow artifact (it is NOT
          # attached to the public Release) so a tester's crash minidump can be
          # symbolised. Empty on Linux/macOS — if-no-files-found: warn covers it.
          path: |
            release/*.AppImage
            release/*.deb
            release/*.exe
            release/velopack/**/*
            build/Release/*.pdb
            # Tester-only macOS archive from the "Zip macOS app" step above.
            # Only produced on non-tag runs; the `release` job's globs below
            # do NOT include *.zip, so it never leaks into a tagged Release.
            release/*.zip
          if-no-files-found: warn
          # Default retention (90 days) snowballed the org Actions storage cap
          # — a single matrix run uploads ~2.5 GB across the three platforms,
          # and old builds piled up for months. Tag builds don't need long
          # retention either: the `release` job downloads and attaches these
          # to the GitHub Release within the same workflow run.
          retention-days: 7

  release:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/download-artifact@v4
        with:
          path: artifacts

      # Every release is published with prerelease=false, but only
      # stable (bare vX.Y.Z) tags get make_latest=true. Reason:
      #
      # The Velopack JS SDK at our pinned version 0.0.1589-ga2c5a97
      # routes any github.com URL through AutoSource → GithubSource,
      # and AutoSource hardcodes the third constructor arg
      # (`prerelease: false`). UpdateOptions has no Prerelease field,
      # so there is no SDK-side override. If we tagged alpha/beta/rc as
      # prerelease=true on GitHub, the in-app updater on those channels
      # would silently see zero updates (verified live during the
      # v0.2.9-alpha.102 validation: toggling the prerelease flag off
      # made the release visible to the running app).
      #
      # Using make_latest=false on alpha/beta/rc tags keeps the GitHub
      # UI's "Latest release" badge pinned to the most recent stable
      # tag — humans browsing the repo still see stable as latest, even
      # though the underlying release rows are all prerelease=false.
      # Per the action-gh-release@v2 docs:
      #   "Drafts and prereleases cannot be set as latest"
      # so we can't use prerelease=true + make_latest=true together.
      #
      # Channel scoping for the auto-updater is independent of either
      # flag — Velopack picks the right release by the channel-named
      # manifest asset (releases.<channel>.json), iterating recent
      # releases until it finds the matching channel.
      #
      # draft is intentionally NOT set — Velopack clients can't see
      # draft releases either way.
      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          # Explicitly select only real distributables:
          # - Linux ships electron-builder .AppImage / .deb
          # - Windows + macOS ship via Velopack (release/velopack/**):
          #   MSI, *-osx.zip, *.nupkg, releases.<channel>.json
          # win-unpacked/** and a bare *.exe glob are deliberately omitted —
          # win-unpacked/ is for CI inspection only.
          # No electron-builder *.dmg / *.zip globs: macOS is Velopack-only,
          # so they would match nothing or duplicate the Velopack *-osx.zip.
          #
          # The velopack glob is `artifacts/**/velopack/**/*`, NOT
          # `artifacts/**/release/velopack/**/*`. actions/upload-artifact@v4
          # strips the longest common path prefix from upload inputs before
          # archiving, so when the build job uploads both
          # `release/win-unpacked/**` and `release/velopack/**`, the common
          # prefix `release/` is stripped and files inside the artifact are
          # rooted at `velopack/...` / `win-unpacked/...`. The old
          # `**/release/velopack/**` glob never matched anything, which is
          # why prior tagged releases shipped only Linux assets and the
          # Windows + macOS Velopack feeds were silently empty (the in-app
          # updater would 404 on releases.<channel>.json and report "no
          # updates available").
          files: |
            artifacts/**/*.AppImage
            artifacts/**/*.deb
            artifacts/**/velopack/**/*
          generate_release_notes: true
          # All tags non-prerelease (Velopack SDK limitation, see the
          # long comment above). Only stable tags become the GitHub
          # "Latest release" — alpha/beta/rc are still visible to the
          # in-app updater but won't be promoted in the repo UI.
          prerelease: false
          make_latest: ${{ (contains(github.ref_name, '-alpha.') || contains(github.ref_name, '-beta.') || contains(github.ref_name, '-rc.')) && 'false' || 'true' }}

  # Mirror release version to slopsmith core
  # Requires FEEDBACK_CLONE_TOKEN secret
  notify-slopsmith:
    needs: release
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    permissions: {}

    steps:
      - name: Dispatch VERSION sync to slopsmith
        env:
          GH_TOKEN: ${{ secrets.FEEDBACK_CLONE_TOKEN }}
          REF_NAME: ${{ github.ref_name }}
        run: |
          version="${REF_NAME#v}"
          if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "Tag $REF_NAME is not vX.Y.Z — skipping slopsmith VERSION sync."
            exit 0
          fi
          if [ -z "${GH_TOKEN:-}" ]; then
            echo "::error::FEEDBACK_CLONE_TOKEN secret is missing; cannot dispatch."
            exit 1
          fi
          gh api -X POST repos/got-feedback/feedback/dispatches \
            -f event_type=desktop-released \
            -f "client_payload[version]=$version"
          echo "Dispatched desktop-released event (version=$version) to slopsmith."
