openlogi 0.6.22

OpenLogi command-line interface — a local-first companion for Logitech HID++ peripherals.
name: Release

on:
  push:
    tags:
      - "v*"
  # Manual, publish-free full build: produces the signed/notarized DMGs and
  # the signed exes as run artifacts. Everything publish-side (GitHub
  # Release, R2 / channels/stable/latest.json, homebrew-tap) is tag-gated
  # below, so a branch dispatch cannot create a release named after the
  # branch, overwrite the updater channel, or ping the tap. Do NOT dispatch
  # on an existing v* tag — that re-enters the full publish path against an
  # immutable release.
  workflow_dispatch:

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  MACOSX_DEPLOYMENT_TARGET: "13.0"
  DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer

jobs:
  # Build (and sign/notarize) every platform's installers. The build matrix
  # lives in a reusable workflow so PRs / manual dispatches can build the same
  # installers UNSIGNED for testing; here we pass sign: true for the signed,
  # notarized artifacts the publish job ships.
  build:
    name: Build installers
    uses: ./.github/workflows/build.yml
    # Required so build.yml can read the 1Password / Azure / R2 signing secrets.
    secrets: inherit
    permissions:
      contents: read
      id-token: write # build.yml mints an OIDC token for Azure Artifact Signing.
    with:
      sign: true

  release-notes:
    name: Generate release notes
    # Tag runs only: the generator diffs from the previous tag, and its only
    # consumer (publish) is tag-gated too.
    if: ${{ github.ref_type == 'tag' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 10.11.0

      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
          cache-dependency-path: scripts/release-notes/pnpm-lock.yaml

      - name: Load Codex release-notes credentials from 1Password
        id: release_notes_config
        continue-on-error: true
        uses: 1password/load-secrets-action@v4
        with:
          export-env: false
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          CODEX_ACCESS_TOKEN: ${{ secrets.OP_CODEX_SECRET_ITEM }}/credential
          CHATGPT_USERNAME: ${{ secrets.OP_CODEX_SECRET_ITEM }}/username
          CODEX_ENDPOINT: ${{ secrets.OP_CODEX_SECRET_ITEM }}/ENDPOINT

      - name: Mint release-notes GitHub App token
        id: release_notes_app_token
        continue-on-error: true
        uses: ./.github/actions/github-app-token-from-1password
        with:
          op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          op-github-app-item: ${{ secrets.OP_GITHUB_APP_ITEM }}

      - name: Install release-notes dependencies
        run: pnpm --dir scripts/release-notes install --frozen-lockfile

      - name: Generate GitHub Release notes
        env:
          CODEX_ACCESS_TOKEN: ${{ steps.release_notes_config.outputs.CODEX_ACCESS_TOKEN }}
          CHATGPT_USERNAME: ${{ steps.release_notes_config.outputs.CHATGPT_USERNAME }}
          CODEX_ENDPOINT: ${{ steps.release_notes_config.outputs.CODEX_ENDPOINT }}
          GH_TOKEN: ${{ steps.release_notes_app_token.outputs.token }}
        run: |
          set -euo pipefail
          node scripts/release-notes/generate.ts \
            --tag "$GITHUB_REF_NAME" \
            --output .release/RELEASE_NOTES.md

      - uses: actions/upload-artifact@v7
        with:
          name: OpenLogi-release-notes
          path: .release/RELEASE_NOTES.md

  publish:
    name: Publish release
    runs-on: ubuntu-latest
    needs: [build, release-notes]
    # Wait for the Windows jobs (so the successful .zip/.msi set can be attached)
    # but do NOT let their failure block the macOS release. `!cancelled()` removes
    # the implicit "all needs succeeded" gate, so the required deps are checked
    # explicitly here — the windows jobs are deliberately absent from this condition.
    # NOTE: after such a partial publish, do NOT "re-run failed jobs" — publish
    # re-runs as build's dependent and fails against the now-immutable release
    # (and would overwrite immutable-cached R2 objects). A missed .zip ships
    # with the next tag instead.
    #
    # `needs.build.outputs.macos_result` is the macOS leg's own result, surfaced
    # by build.yml's `results` job — the reusable-workflow job result aggregates
    # all legs, so gating on it would let a failed Windows/Linux leg block the
    # macOS release. The Windows/Linux legs stay best-effort (pattern download).
    if: ${{ github.ref_type == 'tag' && !cancelled() && needs.build.outputs.macos_result == 'success' && needs.release-notes.result == 'success' }}
    permissions:
      contents: write
    steps:
      # Needed to build & run the xtask manifest generator below.
      - uses: actions/checkout@v6

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@v2

      - uses: actions/download-artifact@v8
        with:
          pattern: OpenLogi-macos-dmg-*
          path: dist
          merge-multiple: true

      # Best-effort per arch — deliberately NOT gated on the Windows leg's
      # result: the proven x86_64 zip must not be held hostage by the
      # experimental arm64 leg (or vice versa). Each leg uploads its artifact
      # only after both exes are built, signed, and verified, so whatever
      # arrived is safe to ship. A pattern download that matches nothing
      # succeeds with an empty dist (unlike by-name, which fails).
      - uses: actions/download-artifact@v8
        with:
          pattern: OpenLogi-windows-*
          path: dist
          merge-multiple: true

      - uses: actions/download-artifact@v8
        with:
          name: OpenLogi-release-notes
          path: .release

      - uses: actions/download-artifact@v8
        with:
          pattern: OpenLogi-linux-packages-*
          path: dist
          merge-multiple: true

      - name: Generate checksums
        run: |
          cd dist
          # The DMGs are the release gate — fail loudly if none arrived (with
          # nullglob alone, sha256sum with zero args would silently hash stdin).
          compgen -G '*.dmg' > /dev/null
          # nullglob: when the Windows job didn't ship a .zip, *.zip drops to
          # nothing instead of erroring on a literal pattern, so the DMGs are
          # hashed alone. The Windows portable artifact is a zip of the GUI +
          # agent exes (#347); no bare .exe ships anymore.
          shopt -s nullglob
          sha256sum -- *.dmg *.zip *.msi *.deb *.rpm *.pkg.tar.zst > SHA256SUMS

      # softprops' `files` can't list a glob that matches nothing without
      # tripping fail_on_unmatched_files. The Windows zip/msi (per arch leg)
      # and the Linux deb/rpm are all best-effort — only the DMGs, SHA256SUMS
      # and the .minisig set are guaranteed by the publish gate — so record
      # what actually arrived. This is what lets a failing Windows or Linux
      # leg degrade to a smaller release instead of blocking the macOS one.
      - name: Detect best-effort artifacts
        id: artifacts
        run: |
          for ext in zip msi deb rpm; do
            if compgen -G "dist/*.$ext" > /dev/null; then
              echo "$ext=true" >> "$GITHUB_OUTPUT"
            else
              echo "$ext=false" >> "$GITHUB_OUTPUT"
            fi
          done
          if compgen -G "dist/*.pkg.tar.zst" > /dev/null; then
            echo "pacman=true" >> "$GITHUB_OUTPUT"
          else
            echo "pacman=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Load static updater publishing config from 1Password
        id: r2_config
        uses: 1password/load-secrets-action@v4
        with:
          export-env: false
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          OPENLOGI_UPDATE_BASE_URL: ${{ secrets.OP_R2_SECRET_ITEM }}/OPENLOGI_UPDATE_BASE_URL
          OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ secrets.OP_R2_SECRET_ITEM }}/OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY
          OPENLOGI_UPDATE_MINISIGN_SECRET_KEY: ${{ secrets.OP_R2_SECRET_ITEM }}/OPENLOGI_UPDATE_MINISIGN_SECRET_KEY
          R2_ACCOUNT_ID: ${{ secrets.OP_R2_SECRET_ITEM }}/CLOUDFLARE_R2_ACCOUNT_ID
          R2_BUCKET: ${{ secrets.OP_R2_SECRET_ITEM }}/CLOUDFLARE_R2_BUCKET
          R2_ACCESS_KEY_ID: ${{ secrets.OP_R2_SECRET_ITEM }}/CLOUDFLARE_R2_ACCESS_KEY_ID
          R2_SECRET_ACCESS_KEY: ${{ secrets.OP_R2_SECRET_ITEM }}/CLOUDFLARE_R2_SECRET_ACCESS_KEY

      - name: Validate R2 publishing configuration
        env:
          OPENLOGI_UPDATE_BASE_URL: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_BASE_URL }}
          OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY }}
          OPENLOGI_UPDATE_MINISIGN_SECRET_KEY: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_MINISIGN_SECRET_KEY }}
          R2_ACCOUNT_ID: ${{ steps.r2_config.outputs.R2_ACCOUNT_ID }}
          R2_BUCKET: ${{ steps.r2_config.outputs.R2_BUCKET }}
          R2_ACCESS_KEY_ID: ${{ steps.r2_config.outputs.R2_ACCESS_KEY_ID }}
          R2_SECRET_ACCESS_KEY: ${{ steps.r2_config.outputs.R2_SECRET_ACCESS_KEY }}
        run: |
          set -euo pipefail
          : "${OPENLOGI_UPDATE_BASE_URL:?Configure OPENLOGI_UPDATE_BASE_URL in the R2 1Password item}"
          : "${OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY:?Configure OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY in the R2 1Password item}"
          : "${OPENLOGI_UPDATE_MINISIGN_SECRET_KEY:?Configure OPENLOGI_UPDATE_MINISIGN_SECRET_KEY in the R2 1Password item}"
          : "${R2_ACCOUNT_ID:?Configure CLOUDFLARE_R2_ACCOUNT_ID in the R2 1Password item}"
          : "${R2_BUCKET:?Configure CLOUDFLARE_R2_BUCKET in the R2 1Password item}"
          : "${R2_ACCESS_KEY_ID:?Configure CLOUDFLARE_R2_ACCESS_KEY_ID in the R2 1Password item}"
          : "${R2_SECRET_ACCESS_KEY:?Configure CLOUDFLARE_R2_SECRET_ACCESS_KEY in the R2 1Password item}"

      - name: Install minisign
        run: |
          sudo apt-get update
          sudo apt-get install -y minisign

      - name: Sign release artifacts with minisign
        env:
          OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY }}
          OPENLOGI_UPDATE_MINISIGN_SECRET_KEY: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_MINISIGN_SECRET_KEY }}
        run: |
          set -euo pipefail
          key_file="${RUNNER_TEMP}/openlogi-minisign.key"
          umask 077
          trap 'rm -f "$key_file"' EXIT
          # The secret key file is stored base64-encoded in 1Password so its two
          # lines survive paste/round-trip (a raw multi-line value can have its
          # newline mangled to a space — see the GitHub App key note above).
          # `tr -d` drops any wrapping whitespace before decoding.
          printf '%s' "$OPENLOGI_UPDATE_MINISIGN_SECRET_KEY" | tr -d '[:space:]' | base64 -d > "$key_file"
          # The Windows binaries and Linux packages get the same minisign
          # treatment as the DMGs: manual verification today, and the future
          # auto-updaters need the detached signatures to exist for every
          # shipped version.
          # nullglob: the zip/msi/deb/rpm sets are best-effort per arch leg, so
          # the globs may match nothing; the DMGs are guaranteed by the publish
          # gate.
          shopt -s nullglob
          for artifact in dist/*.dmg dist/*.zip dist/*.msi dist/*.deb dist/*.rpm dist/*.pkg.tar.zst; do
            minisign -S -m "$artifact" -s "$key_file" -x "$artifact.minisig" -W
            minisign -V -m "$artifact" -P "$OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY" -x "$artifact.minisig"
          done

      - name: Generate static updater manifest
        env:
          OPENLOGI_UPDATE_BASE_URL: ${{ steps.r2_config.outputs.OPENLOGI_UPDATE_BASE_URL }}
        run: |
          set -euo pipefail
          cargo run -p xtask -- release latest-json \
            --dist dist \
            --tag "$GITHUB_REF_NAME" \
            --base-url "$OPENLOGI_UPDATE_BASE_URL" \
            --output dist/latest.json

      - name: Upload static updater assets to R2
        env:
          AWS_ACCESS_KEY_ID: ${{ steps.r2_config.outputs.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ steps.r2_config.outputs.R2_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: auto
          AWS_REGION: auto
          R2_ACCOUNT_ID: ${{ steps.r2_config.outputs.R2_ACCOUNT_ID }}
          R2_BUCKET: ${{ steps.r2_config.outputs.R2_BUCKET }}
        run: |
          set -euo pipefail
          endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
          release_prefix="s3://${R2_BUCKET}/releases/${GITHUB_REF_NAME}"
          channel_manifest="s3://${R2_BUCKET}/channels/stable/latest.json"

          # The Windows binaries' distribution channel is the GitHub Release;
          # latest.json is still dmg-only, so nothing in the updater bucket
          # references the zip/msi or their minisigs — keep them all out of
          # the immutable releases/ prefix until the Windows auto-updater
          # lands (#347 PR 4). SHA256SUMS still lists them — it describes the
          # GitHub Release's asset set. *.exe stays excluded defensively even
          # though no bare exe ships anymore.
          aws s3 cp dist/ "$release_prefix/" \
            --recursive \
            --exclude latest.json \
            --exclude "*.exe" \
            --exclude "*.exe.minisig" \
            --exclude "*.zip" \
            --exclude "*.zip.minisig" \
            --exclude "*.msi" \
            --exclude "*.msi.minisig" \
            --cache-control "public, max-age=31536000, immutable" \
            --endpoint-url "$endpoint"

          aws s3 cp dist/latest.json "$channel_manifest" \
            --content-type "application/json" \
            --cache-control "no-cache" \
            --endpoint-url "$endpoint"

      # Create the draft, upload assets to it, then publish — all in one step.
      # `draft` is omitted, so for a non-prerelease softprops creates the release as
      # a draft, uploads every file, and only then flips it to published. That keeps
      # the release mutable for the whole upload, which is what avoids the "immutable
      # release" rejection. latest.json is intentionally not attached — it lives in
      # R2 only; RELEASE_NOTES.md is kept outside dist for the same reason.
      - name: Publish GitHub Release with assets
        uses: softprops/action-gh-release@v3
        with:
          body_path: .release/RELEASE_NOTES.md
          files: |
            dist/*.dmg
            dist/*.minisig
            dist/SHA256SUMS
            ${{ steps.artifacts.outputs.zip == 'true' && 'dist/*.zip' || '' }}
            ${{ steps.artifacts.outputs.msi == 'true' && 'dist/*.msi' || '' }}
            ${{ steps.artifacts.outputs.deb == 'true' && 'dist/*.deb' || '' }}
            ${{ steps.artifacts.outputs.rpm == 'true' && 'dist/*.rpm' || '' }}
            ${{ steps.artifacts.outputs.pacman == 'true' && 'dist/*.pkg.tar.zst' || '' }}
          fail_on_unmatched_files: true

  homebrew-tap:
    name: Dispatch homebrew-tap update
    runs-on: ubuntu-latest
    needs: publish
    permissions:
      contents: read
    steps:
      # The token-minting action below is a local action, so the repo must be
      # on disk before it can run.
      - uses: actions/checkout@v6

      - name: Mint homebrew-tap token (GitHub App)
        id: tap_token
        uses: ./.github/actions/github-app-token-from-1password
        with:
          op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          op-github-app-item: ${{ secrets.OP_GITHUB_APP_ITEM }}
          owner: AprilNEA
          repositories: homebrew-tap

      - name: Dispatch homebrew-tap update
        uses: peter-evans/repository-dispatch@v4
        with:
          token: ${{ steps.tap_token.outputs.token }}
          repository: AprilNEA/homebrew-tap
          event-type: update-openlogi
          client-payload: |
            {"version": "${{ github.ref_name }}"}