From 7bf73d28e9b2cb13a97e1188b18a756ec7cda154 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:06:19 -0500 Subject: [PATCH 1/8] CI: split into per-project path-filtered workflows + per-platform releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo holds three FT8 clients sharing one C DSP core, but all CI was Android-only and fired on every push/PR with no path filtering — an iOS-only change ran the full Android NDK build, and iOS/desktop had no CI at all. Split into per-project pipelines that only run when their files (or the shared core at ft8af/app/src/main/cpp/**) change: - android.yml: renamed from build-release.yml, folds in static-analysis.yml as a job, adds a `detect` (dorny/paths-filter) gate and an always-run `android-gate` aggregator. All existing behavior (NDK retry, signing, tag derivation, GitHub Release, Play internal publish, play-publish concurrency) is preserved; job names test/instrumented/build are unchanged. - ios.yml (new): swift test on FT8AFKit + unsigned iOS-Simulator build (xcodegen generate -> xcodebuild) on macos-14; `ios-gate` aggregator. - desktop.yml (new): Tauri compile-check on Windows/macOS/Linux for PRs and non-release pushes; on main / desktop-v* tags builds native bundles on all three OSes and publishes a namespaced desktop-v* / desktop-dev-N release via tauri-action; `desktop-gate` aggregator. Behavior model: PRs run only the affected project's checks; dev pushes build only affected platform(s) as prereleases; main pushes / tags build everything (one release per platform). native-tests.yml stays always-on as the shared-core gate; main-gate.yml and the discord workflows are untouched. Because path-filtered jobs can be skipped, the *-gate jobs always run and are the intended required status checks (a skipped required check otherwise leaves branch protection stuck "pending"). Branch protection must be updated to require android-gate / ios-gate / desktop-gate instead of the inner jobs — documented in the PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../{build-release.yml => android.yml} | 169 +++++++++++- .github/workflows/desktop.yml | 240 ++++++++++++++++++ .github/workflows/ios.yml | 121 +++++++++ .github/workflows/static-analysis.yml | 82 ------ 4 files changed, 520 insertions(+), 92 deletions(-) rename .github/workflows/{build-release.yml => android.yml} (73%) create mode 100644 .github/workflows/desktop.yml create mode 100644 .github/workflows/ios.yml delete mode 100644 .github/workflows/static-analysis.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/android.yml similarity index 73% rename from .github/workflows/build-release.yml rename to .github/workflows/android.yml index acb2fb1e..ff38cb25 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/android.yml @@ -1,4 +1,16 @@ -name: Build & Release APK +name: Android CI & Release + +# Android pipeline for the ft8af/ app: unit tests + coverage, instrumented +# emulator tests, static analysis (lint/detekt/ktlint), and the signed +# APK/AAB build + GitHub Release + Play internal publish. +# +# PATH-FILTERED: on pull requests and dev pushes this whole pipeline only runs +# when Android-relevant files change (ft8af/** — which includes the shared +# native C core at ft8af/app/src/main/cpp/ — or this workflow file). Pushes to +# main and v* tags always build (a full per-version release snapshot, +# regardless of what changed). The always-run `android-gate` job is the single +# required status check so branch protection never gets stuck "pending" when the +# build jobs are path-skipped. on: push: @@ -31,9 +43,44 @@ concurrency: cancel-in-progress: false jobs: + detect: + name: Detect Android changes + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Filter paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + android: + - 'ft8af/**' + - '.github/workflows/android.yml' + + - name: Decide whether to run + id: decide + env: + CHANGED: ${{ steps.filter.outputs.android }} + run: | + set -euo pipefail + # main pushes and v* tags always build the full release; otherwise + # build only when Android-relevant files changed. + if [[ "$GITHUB_EVENT_NAME" == "push" && ( "$GITHUB_REF" == "refs/heads/main" || "$GITHUB_REF" == refs/tags/v* ) ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif [[ "$CHANGED" == "true" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + test: name: Unit tests & coverage - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + needs: detect + if: ${{ needs.detect.outputs.run == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) }} runs-on: ubuntu-latest steps: @@ -110,10 +157,12 @@ jobs: name: Instrumented tests (emulator) # Exercises the androidTest suite (AppSmokeTest launches the real # ComposeMainActivity) on an emulator. Same trigger as the unit-test job: - # every PR plus pushes to main. This job is intentionally NOT a dependency - # of `build` and is left out of branch protection's required checks while it - # stabilises (issue #60) — a flaky emulator must never block a release. - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + # every Android-affected PR plus pushes to main. This job is intentionally + # NOT part of android-gate and is left out of branch protection's required + # checks while it stabilises (issue #60) — a flaky emulator must never block + # a release. + needs: detect + if: ${{ needs.detect.outputs.run == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) }} runs-on: ubuntu-latest steps: @@ -209,12 +258,85 @@ jobs: ft8af/app/build/reports/androidTests/ ft8af/app/build/outputs/androidTest-results/ + static-analysis: + name: lint / detekt / ktlint + # Issue #63: Android lint, detekt, and ktlint on PRs. + # + # ROLLOUT IS PHASED — every analysis step below is currently WARN-ONLY + # (`continue-on-error: true`). CI runs the tool and uploads its report, but a + # violation does NOT fail the check. To FLIP A TOOL TO BLOCKING: delete its + # `continue-on-error: true` line. Do them one at a time. + # + # Notes on what already gates new code even in warn-only mode: + # - detekt runs against config/detekt/baseline.xml, so it only reports NEW + # correctness findings; the baseline holds the existing ones. + # - ktlint has no baseline yet — the existing codebase isn't formatted, so + # it will report many violations until a formatting pass lands. + needs: detect + if: ${{ needs.detect.outputs.run == 'true' && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('ft8af/**/*.gradle*', 'ft8af/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x ft8af/gradlew + + # --- WARN-ONLY during rollout: remove continue-on-error to make blocking --- + + - name: Android lint (debug) + id: lint + continue-on-error: true + working-directory: ft8af + run: ./gradlew lintDebug --stacktrace + + - name: detekt + id: detekt + continue-on-error: true + working-directory: ft8af + run: ./gradlew detekt --stacktrace + + - name: ktlint + id: ktlint + continue-on-error: true + working-directory: ft8af + run: ./gradlew ktlintCheck --stacktrace + + - name: Upload analysis reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: static-analysis-reports + path: | + ft8af/app/build/reports/lint-results-debug.html + ft8af/app/build/reports/lint-results-debug.xml + ft8af/app/build/reports/detekt/ + ft8af/app/build/reports/ktlint/ + if-no-files-found: ignore + build: name: Build APK - needs: test - # Run when tests pass (PR/main push), or when tests were skipped (push to main/dev/tags). - # Block only on actual test failure or cancellation. - if: ${{ always() && needs.test.result != 'failure' && needs.test.result != 'cancelled' }} + needs: [detect, test] + # Run when Android is in scope (detect.run) AND tests passed (PR/main push) or + # were skipped (push to dev/tags). Block only on actual test failure/cancel. + if: ${{ always() && needs.detect.outputs.run == 'true' && needs.test.result != 'failure' && needs.test.result != 'cancelled' }} runs-on: ubuntu-latest steps: @@ -442,3 +564,30 @@ jobs: track: internal status: completed releaseName: ${{ steps.tag.outputs.release_tag }} + + android-gate: + name: android-gate + # Always-run aggregator: the single required status check for Android. + # Because the build jobs above are path-skipped on unrelated changes, this + # job (which always runs) reports the overall Android result so branch + # protection never gets stuck "pending". It fails only if a REQUIRED job + # actually failed/cancelled. `instrumented` and `static-analysis` are + # deliberately excluded — instrumented is flaky (issue #60) and static + # analysis is warn-only during rollout — so neither can block a merge. + needs: [test, build] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify required Android jobs succeeded or were skipped + env: + TEST_RESULT: ${{ needs.test.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + set -euo pipefail + for r in "$TEST_RESULT" "$BUILD_RESULT"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "::error::A required Android job did not pass (result: $r)" + exit 1 + fi + done + echo "Android gate OK (test=$TEST_RESULT build=$BUILD_RESULT)." diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000..e575a15c --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,240 @@ +name: Desktop CI & Release + +# Desktop pipeline for the desktop/ Tauri port (Rust + Vite/React). On PRs and +# non-release pushes it does a compile check (`tauri build --no-bundle`) across +# Windows + macOS + Linux. On main pushes and desktop-v* tags it builds native +# bundles on all three OSes and publishes them to a per-platform GitHub Release. +# +# Release scheme is namespaced so it sits separately from the Android v* / dev-N +# releases on the Releases page: +# desktop-v* tag -> production desktop release +# push to main -> auto-bumped desktop-v* release +# push to dev -> desktop-dev- PRERELEASE +# +# PATH-FILTERED: PRs and dev pushes only build when desktop/** or the shared +# native C core (ft8af/app/src/main/cpp/**) changes, or this workflow file. +# main pushes and desktop-v* tags always build. The always-run `desktop-gate` +# job is the required status check. + +on: + push: + branches: [main, dev] + tags: + - 'desktop-v*' + pull_request: + branches: [main, dev] + +permissions: + contents: write + +concurrency: + group: >- + ${{ + (github.event_name == 'push' + && (github.ref == 'refs/heads/main' + || github.ref == 'refs/heads/dev' + || startsWith(github.ref, 'refs/tags/desktop-v'))) + && 'desktop-release' + || format('desktop-{0}', github.run_id) + }} + cancel-in-progress: false + +jobs: + detect: + name: Detect desktop changes + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Filter paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + desktop: + - 'ft8af/app/src/main/cpp/**' + - 'desktop/**' + - '.github/workflows/desktop.yml' + + - name: Decide whether to run + id: decide + env: + CHANGED: ${{ steps.filter.outputs.desktop }} + run: | + set -euo pipefail + # main pushes and desktop-v* tags always build the full release; + # otherwise build only when desktop-relevant files changed. + if [[ "$GITHUB_EVENT_NAME" == "push" && ( "$GITHUB_REF" == "refs/heads/main" || "$GITHUB_REF" == refs/tags/desktop-v* ) ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif [[ "$CHANGED" == "true" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + version: + name: Determine desktop release tag + needs: detect + if: ${{ needs.detect.outputs.run == 'true' }} + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.tag.outputs.release_tag }} + version_name: ${{ steps.tag.outputs.version_name }} + should_release: ${{ steps.tag.outputs.should_release }} + is_prerelease: ${{ steps.tag.outputs.is_prerelease }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch tags + run: git fetch --tags --force + + - name: Determine release tag + id: tag + run: | + set -euo pipefail + # Mirrors the Android lane logic but in the desktop-v* / desktop-dev-N + # namespace so desktop releases sort separately from the app's. + if [[ "${GITHUB_REF}" == refs/tags/desktop-v* ]]; then + echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "version_name=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/main" ]]; then + # Auto-bump the last component from the latest desktop-v* tag. + latest=$(git tag --list 'desktop-v*' --sort=-v:refname | head -n1) + if [[ -z "$latest" ]]; then + new_tag="desktop-v0.1.0" + else + version="${latest#desktop-v}" + IFS='.' read -ra parts <<< "$version" + last_idx=$((${#parts[@]} - 1)) + parts[$last_idx]=$((parts[$last_idx] + 1)) + new_tag="desktop-v$(IFS=.; echo "${parts[*]}")" + fi + echo "Latest tag: ${latest:-} -> new tag: $new_tag" + echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" + echo "version_name=${new_tag#desktop-v}" >> "$GITHUB_OUTPUT" + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/dev" ]]; then + new_tag="desktop-dev-${GITHUB_RUN_NUMBER}" + latest=$(git tag --list 'desktop-v*' --sort=-v:refname | head -n1) + base_ver="${latest:+${latest#desktop-v}}" + base_ver="${base_ver:-0.1.0}" + echo "release_tag=$new_tag" >> "$GITHUB_OUTPUT" + echo "version_name=${base_ver}-dev.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + # PR or other branch push — compile-check only, no release. + echo "release_tag=" >> "$GITHUB_OUTPUT" + echo "version_name=" >> "$GITHUB_OUTPUT" + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + build: + name: Build (${{ matrix.os }}) + needs: [detect, version] + if: ${{ needs.detect.outputs.run == 'true' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri + + # Tauri v2 Linux system dependencies (webkit2gtk-4.1 + GTK + packaging). + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libappindicator3-dev \ + patchelf \ + file \ + build-essential \ + curl \ + wget + + # build.rs compiles the C core with clang-cl on the windows-msvc target + # (MSVC's cl.exe rejects ft8_lib's C99 VLAs). LLVM/clang-cl is preinstalled + # on the GitHub windows runners; this just confirms it's resolvable. + - name: Verify clang-cl (Windows) + if: runner.os == 'Windows' + shell: bash + run: clang-cl --version || echo "::warning::clang-cl not on PATH; build.rs falls back to C:\\Program Files\\LLVM\\bin" + + - name: Install frontend dependencies + working-directory: desktop + run: npm ci + + # --- Non-release: compile check only (no bundles), fastest path. --- + - name: Compile check (no bundle) + if: ${{ needs.version.outputs.should_release != 'true' }} + working-directory: desktop + run: npm run tauri build -- --no-bundle + + # --- Release: build native bundles and upload to the per-platform release. + # tauri.conf.json has bundle.active=false (keeps local dev builds bundle- + # free), so enable bundling for the release build via an inline --config + # merge. Each OS produces its native targets (dmg/app, msi/nsis, + # deb/appimage) from the base config's targets:"all". All matrix legs share + # one tagName; tauri-action creates the release once and attaches to it. --- + - name: Build & publish release bundles + if: ${{ needs.version.outputs.should_release == 'true' }} + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: desktop + tagName: ${{ needs.version.outputs.release_tag }} + releaseName: ${{ needs.version.outputs.release_tag }} + releaseBody: 'FT8AF desktop build ${{ needs.version.outputs.version_name }}.' + prerelease: ${{ needs.version.outputs.is_prerelease == 'true' }} + args: --config '{"bundle":{"active":true}}' + + desktop-gate: + name: desktop-gate + # Always-run aggregator: the single required status check for desktop. + # Reports success when every build leg passed OR the build was path-skipped, + # so branch protection never gets stuck "pending" on an unrelated change. + needs: build + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify desktop build succeeded or was skipped + env: + BUILD_RESULT: ${{ needs.build.result }} + run: | + set -euo pipefail + if [[ "$BUILD_RESULT" == "failure" || "$BUILD_RESULT" == "cancelled" ]]; then + echo "::error::Desktop build did not pass (result: $BUILD_RESULT)" + exit 1 + fi + echo "Desktop gate OK (build=$BUILD_RESULT)." diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 00000000..b323f679 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,121 @@ +name: iOS CI + +# iOS pipeline for the ios/ port. Runs the FT8AFKit host test suites +# (FT8DSP/FT8Audio/FT8Engine/FT8Rig — the encode/decode/golden gate compiled +# under Apple clang) and an unsigned iOS-Simulator build of the FT8AF app to +# prove it compiles. No release artifact: a distributable .ipa needs an Apple +# Developer cert + provisioning profile, which aren't wired up yet. +# +# PATH-FILTERED: only runs when ios/** or the shared native C core +# (ft8af/app/src/main/cpp/**) changes, or this workflow file. Pushes to main +# always verify. The always-run `ios-gate` job is the required status check. + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +concurrency: + group: ios-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + detect: + name: Detect iOS changes + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Filter paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + ios: + - 'ft8af/app/src/main/cpp/**' + - 'ios/**' + - '.github/workflows/ios.yml' + + - name: Decide whether to run + id: decide + env: + CHANGED: ${{ steps.filter.outputs.ios }} + run: | + set -euo pipefail + # main pushes always verify; otherwise only when iOS-relevant files + # (or the shared C core) changed. + if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif [[ "$CHANGED" == "true" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + verify: + name: swift test + simulator build + needs: detect + if: ${{ needs.detect.outputs.run == 'true' }} + # macos-14 ships Xcode 15 (project.yml pins xcodeVersion 15.0 / iOS 17). + runs-on: macos-14 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # FT8AFKit declares a macOS platform so its pure-logic suites run on the + # host with no simulator. This is the real DSP gate: green here means the + # shared C core (#included in place from ../../ft8af/app/src/main/cpp) is + # bit-identical under Apple clang. Surfaces the ios/README.md + # header-search-path / non-modular-include caveats if any regress. + - name: Run FT8AFKit tests + working-directory: ios/FT8AFKit + run: swift test + + # The app's .xcodeproj is generated from project.yml via XcodeGen and no + # shared scheme is committed, so regenerate it to get a buildable scheme. + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: ios/FT8AF + run: xcodegen generate + + # Unsigned simulator build — proves the SwiftUI app + FT8AFKit link and + # compile. CODE_SIGNING_ALLOWED=NO so no certs/provisioning are needed. + - name: Build app for iOS Simulator (unsigned) + working-directory: ios/FT8AF + run: | + set -euo pipefail + xcodebuild build \ + -project FT8AF.xcodeproj \ + -scheme FT8AF \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + ios-gate: + name: ios-gate + # Always-run aggregator: the single required status check for iOS. Reports + # success when `verify` passed OR was path-skipped, so branch protection + # never gets stuck "pending" on an unrelated change. + needs: verify + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify iOS job succeeded or was skipped + env: + VERIFY_RESULT: ${{ needs.verify.result }} + run: | + set -euo pipefail + if [[ "$VERIFY_RESULT" == "failure" || "$VERIFY_RESULT" == "cancelled" ]]; then + echo "::error::iOS verify did not pass (result: $VERIFY_RESULT)" + exit 1 + fi + echo "iOS gate OK (verify=$VERIFY_RESULT)." diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index 9da514e5..00000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Static analysis - -# Issue #63: run Android lint, detekt, and ktlint on PRs. -# -# ROLLOUT IS PHASED — every analysis step below is currently WARN-ONLY -# (`continue-on-error: true`). CI runs the tool and uploads its report, but a -# violation does NOT fail the check. This lets the team see a few PRs' worth of -# output before any of these gate merges. -# -# To FLIP A TOOL TO BLOCKING: delete its `continue-on-error: true` line. Do them -# one at a time, not all three at once. -# -# Notes on what already gates new code even in warn-only mode: -# - detekt runs against config/detekt/baseline.xml, so it only reports NEW -# correctness findings; the baseline holds the existing ones. -# - ktlint has no baseline yet — the existing codebase isn't formatted, so it -# will report many violations until a formatting pass lands. That's why it -# stays warn-only here. - -on: - pull_request: - branches: [main, dev] - -jobs: - static-analysis: - name: lint / detekt / ktlint - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('ft8af/**/*.gradle*', 'ft8af/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Grant execute permission for gradlew - run: chmod +x ft8af/gradlew - - # --- WARN-ONLY during rollout: remove continue-on-error to make blocking --- - - - name: Android lint (debug) - id: lint - continue-on-error: true - working-directory: ft8af - run: ./gradlew lintDebug --stacktrace - - - name: detekt - id: detekt - continue-on-error: true - working-directory: ft8af - run: ./gradlew detekt --stacktrace - - - name: ktlint - id: ktlint - continue-on-error: true - working-directory: ft8af - run: ./gradlew ktlintCheck --stacktrace - - - name: Upload analysis reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: static-analysis-reports - path: | - ft8af/app/build/reports/lint-results-debug.html - ft8af/app/build/reports/lint-results-debug.xml - ft8af/app/build/reports/detekt/ - ft8af/app/build/reports/ktlint/ - if-no-files-found: ignore From 1138feaed46b255899929873645fafe5d6792fa2 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:25:13 -0500 Subject: [PATCH 2/8] CI: harden gate jobs against masked upstream failures; fix iOS host build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #296: - android-gate / ios-gate / desktop-gate now depend on (and check) their detect/version prerequisites, not just the build/verify/test legs. A failed `detect` (or desktop `version`) skips the downstream jobs, and a skip alone was being read as "path-filtered, nothing to do" — letting a broken workflow report success on the required check. Each gate now fails if any prerequisite result is failure/cancelled; a genuine path-skip (detect succeeded, run=false) still passes. Also fix the iOS verify job, which failed to compile: ft8_lib's `fmtmsg` collided with the POSIX `fmtmsg(long, ...)` from , visible when the shared C core is built under the macOS/Apple SDK (FT8AFKit swift test). Rename the unused helper to ft8_fmtmsg (no call sites anywhere in the repo). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9 --- .github/workflows/android.yml | 12 ++++++++---- .github/workflows/desktop.yml | 21 ++++++++++++++------- .github/workflows/ios.yml | 21 +++++++++++++-------- ft8af/app/src/main/cpp/ft8_lib/ft8/text.c | 2 +- ft8af/app/src/main/cpp/ft8_lib/ft8/text.h | 5 ++++- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ff38cb25..a52950e5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -574,20 +574,24 @@ jobs: # actually failed/cancelled. `instrumented` and `static-analysis` are # deliberately excluded — instrumented is flaky (issue #60) and static # analysis is warn-only during rollout — so neither can block a merge. - needs: [test, build] + # `detect` is included: a `detect` failure skips `test`/`build`, and a skip + # alone would otherwise be read as "nothing to do" and pass the gate — so + # its result is checked directly. + needs: [detect, test, build] if: always() runs-on: ubuntu-latest steps: - name: Verify required Android jobs succeeded or were skipped env: + DETECT_RESULT: ${{ needs.detect.result }} TEST_RESULT: ${{ needs.test.result }} BUILD_RESULT: ${{ needs.build.result }} run: | set -euo pipefail - for r in "$TEST_RESULT" "$BUILD_RESULT"; do + for r in "$DETECT_RESULT" "$TEST_RESULT" "$BUILD_RESULT"; do if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then - echo "::error::A required Android job did not pass (result: $r)" + echo "::error::A required Android job did not pass (detect=$DETECT_RESULT test=$TEST_RESULT build=$BUILD_RESULT)" exit 1 fi done - echo "Android gate OK (test=$TEST_RESULT build=$BUILD_RESULT)." + echo "Android gate OK (detect=$DETECT_RESULT test=$TEST_RESULT build=$BUILD_RESULT)." diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index e575a15c..dd5d2df5 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -224,17 +224,24 @@ jobs: # Always-run aggregator: the single required status check for desktop. # Reports success when every build leg passed OR the build was path-skipped, # so branch protection never gets stuck "pending" on an unrelated change. - needs: build + # Depends on `detect` and `version` too: a failure in either skips `build`, + # and a skip alone would otherwise be read as "nothing to do" and pass the + # gate — so their results are checked directly. + needs: [detect, version, build] if: always() runs-on: ubuntu-latest steps: - - name: Verify desktop build succeeded or was skipped + - name: Verify desktop jobs succeeded or were skipped env: + DETECT_RESULT: ${{ needs.detect.result }} + VERSION_RESULT: ${{ needs.version.result }} BUILD_RESULT: ${{ needs.build.result }} run: | set -euo pipefail - if [[ "$BUILD_RESULT" == "failure" || "$BUILD_RESULT" == "cancelled" ]]; then - echo "::error::Desktop build did not pass (result: $BUILD_RESULT)" - exit 1 - fi - echo "Desktop gate OK (build=$BUILD_RESULT)." + for r in "$DETECT_RESULT" "$VERSION_RESULT" "$BUILD_RESULT"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "::error::A desktop job did not pass (detect=$DETECT_RESULT version=$VERSION_RESULT build=$BUILD_RESULT)" + exit 1 + fi + done + echo "Desktop gate OK (detect=$DETECT_RESULT version=$VERSION_RESULT build=$BUILD_RESULT)." diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index b323f679..a9ec985d 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -104,18 +104,23 @@ jobs: name: ios-gate # Always-run aggregator: the single required status check for iOS. Reports # success when `verify` passed OR was path-skipped, so branch protection - # never gets stuck "pending" on an unrelated change. - needs: verify + # never gets stuck "pending" on an unrelated change. Depends on `detect` + # too: a `detect` failure skips `verify`, and a skip alone would otherwise + # be read as "nothing to do" and pass the gate — so its result is checked. + needs: [detect, verify] if: always() runs-on: ubuntu-latest steps: - - name: Verify iOS job succeeded or was skipped + - name: Verify iOS jobs succeeded or were skipped env: + DETECT_RESULT: ${{ needs.detect.result }} VERIFY_RESULT: ${{ needs.verify.result }} run: | set -euo pipefail - if [[ "$VERIFY_RESULT" == "failure" || "$VERIFY_RESULT" == "cancelled" ]]; then - echo "::error::iOS verify did not pass (result: $VERIFY_RESULT)" - exit 1 - fi - echo "iOS gate OK (verify=$VERIFY_RESULT)." + for r in "$DETECT_RESULT" "$VERIFY_RESULT"; do + if [[ "$r" == "failure" || "$r" == "cancelled" ]]; then + echo "::error::An iOS job did not pass (detect=$DETECT_RESULT verify=$VERIFY_RESULT)" + exit 1 + fi + done + echo "iOS gate OK (detect=$DETECT_RESULT verify=$VERIFY_RESULT)." diff --git a/ft8af/app/src/main/cpp/ft8_lib/ft8/text.c b/ft8af/app/src/main/cpp/ft8_lib/ft8/text.c index 869f6fc1..557ecfeb 100644 --- a/ft8af/app/src/main/cpp/ft8_lib/ft8/text.c +++ b/ft8af/app/src/main/cpp/ft8_lib/ft8/text.c @@ -90,7 +90,7 @@ bool equals(const char* string1, const char* string2) // Text message formatting: // - replaces lowercase letters with uppercase // - merges consecutive spaces into single space -void fmtmsg(char* msg_out, const char* msg_in) +void ft8_fmtmsg(char* msg_out, const char* msg_in) { char c; char last_out = 0; diff --git a/ft8af/app/src/main/cpp/ft8_lib/ft8/text.h b/ft8af/app/src/main/cpp/ft8_lib/ft8/text.h index dd90e700..7a24386d 100644 --- a/ft8af/app/src/main/cpp/ft8_lib/ft8/text.h +++ b/ft8af/app/src/main/cpp/ft8_lib/ft8/text.h @@ -35,7 +35,10 @@ bool equals(const char* string1, const char* string2); // Text message formatting: // - replaces lowercase letters with uppercase // - merges consecutive spaces into single space -void fmtmsg(char* msg_out, const char* msg_in); +// Named ft8_fmtmsg (not the upstream `fmtmsg`) to avoid colliding with the +// POSIX `fmtmsg(long, ...)` declared in , which is visible when this +// header is compiled under the macOS/Apple SDK (e.g. FT8AFKit `swift test`). +void ft8_fmtmsg(char* msg_out, const char* msg_in); /// Extract and copy a space-delimited token from a string. /// When the last token has been extracted, the return value points to the terminating zero character. From 98aad14cff7baeba64ef4637c4c5d7800d193366 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:28:44 -0500 Subject: [PATCH 3/8] iOS: pin XcodeGen objectVersion to 56 so CI's Xcode 15 can open the project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ios.yml simulator-build leg failed with "future Xcode project file format (77)": XcodeGen 2.45.4 defaults the generated pbxproj to objectVersion 77 (Xcode 16-only), but the macos-14 runner's Xcode 15.4 — matching the project's pinned xcodeVersion 15.0 — can't read it. Pin objectVersion 56, which Xcode 15 opens and Xcode 16 still accepts. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9 --- ios/FT8AF/project.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/FT8AF/project.yml b/ios/FT8AF/project.yml index cd47b8b4..fab83241 100644 --- a/ios/FT8AF/project.yml +++ b/ios/FT8AF/project.yml @@ -4,6 +4,12 @@ options: deploymentTarget: iOS: "17.0" xcodeVersion: "15.0" + # Pin the pbxproj objectVersion so the generated project opens in Xcode 15 + # (the version pinned above, and what CI's macos-14 runner ships). Recent + # XcodeGen defaults to objectVersion 77, an Xcode 16-only format that + # Xcode 15.4 refuses with "future Xcode project file format (77)". 56 is + # readable by Xcode 15 and still opens fine in 16. + objectVersion: 56 generateEmptyDirectories: true packages: From d41189b379a9c247200ce3380e29671105c0370f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:30:34 -0500 Subject: [PATCH 4/8] Revert "iOS: pin XcodeGen objectVersion to 56 so CI's Xcode 15 can open the project" This reverts commit 98aad14cff7baeba64ef4637c4c5d7800d193366. --- ios/FT8AF/project.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ios/FT8AF/project.yml b/ios/FT8AF/project.yml index fab83241..cd47b8b4 100644 --- a/ios/FT8AF/project.yml +++ b/ios/FT8AF/project.yml @@ -4,12 +4,6 @@ options: deploymentTarget: iOS: "17.0" xcodeVersion: "15.0" - # Pin the pbxproj objectVersion so the generated project opens in Xcode 15 - # (the version pinned above, and what CI's macos-14 runner ships). Recent - # XcodeGen defaults to objectVersion 77, an Xcode 16-only format that - # Xcode 15.4 refuses with "future Xcode project file format (77)". 56 is - # readable by Xcode 15 and still opens fine in 16. - objectVersion: 56 generateEmptyDirectories: true packages: From ee2cf8cde99de35fd03a3316cc63e439870448ba Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:30:55 -0500 Subject: [PATCH 5/8] CI(iOS): build on macos-15 (Xcode 16) to read XcodeGen's project format XcodeGen 2.45.x always emits the pbxproj in Xcode 16's object format (objectVersion 77); Xcode 15.4 on the macos-14 runner can't open it ("future Xcode project file format (77)"). Pinning objectVersion via project.yml had no effect (not an honored XcodeGen option), so build the simulator leg on macos-15 / Xcode 16 instead. The iOS 17 deployment target still builds under the newer toolchain. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9 --- .github/workflows/ios.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index a9ec985d..15ff4d94 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -60,8 +60,12 @@ jobs: name: swift test + simulator build needs: detect if: ${{ needs.detect.outputs.run == 'true' }} - # macos-14 ships Xcode 15 (project.yml pins xcodeVersion 15.0 / iOS 17). - runs-on: macos-14 + # macos-15 ships Xcode 16. Current XcodeGen (2.45.x) always writes the + # pbxproj in the Xcode 16 object format (objectVersion 77), which Xcode 15 + # refuses to open ("future Xcode project file format (77)"), so the + # simulator build needs Xcode 16 to read the generated project. The app's + # iOS 17 deployment target still builds fine under the newer toolchain. + runs-on: macos-15 steps: - name: Checkout repository From c32e67201cf9ea6f2f310231f22952364c419011 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 14:48:16 -0500 Subject: [PATCH 6/8] CI(desktop): install libudev-dev for the Linux serialport build The ubuntu desktop build failed in libudev-sys's build script: "Package libudev was not found in the pkg-config search path." The serialport crate (CAT rig control) needs libudev on Linux, which the apt step wasn't installing. Add libudev-dev alongside the existing Tauri/GTK system deps. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9 --- .github/workflows/desktop.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index dd5d2df5..0eccbc63 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -166,7 +166,10 @@ jobs: with: workspaces: desktop/src-tauri - # Tauri v2 Linux system dependencies (webkit2gtk-4.1 + GTK + packaging). + # Tauri v2 Linux system dependencies (webkit2gtk-4.1 + GTK + packaging), + # plus libudev-dev for the serialport crate's libudev-sys build (CAT rig + # control) — without it the Linux build fails resolving the `libudev` + # pkg-config package. - name: Install Linux dependencies if: runner.os == 'Linux' run: | @@ -176,6 +179,7 @@ jobs: libgtk-3-dev \ librsvg2-dev \ libappindicator3-dev \ + libudev-dev \ patchelf \ file \ build-essential \ From e92597c944c7563509b512f6a9b8a8feeb5053ad Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 15:34:41 -0500 Subject: [PATCH 7/8] CI(desktop): install libasound2-dev for the Linux alsa-sys build With libudev-dev in place the Linux build now gets past serialport and fails next in alsa-sys: "Package alsa was not found in the pkg-config search path." The audio crate (cpal/rodio) needs ALSA on Linux. Add libasound2-dev to the apt step. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9 --- .github/workflows/desktop.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 0eccbc63..5bea65f1 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -167,9 +167,11 @@ jobs: workspaces: desktop/src-tauri # Tauri v2 Linux system dependencies (webkit2gtk-4.1 + GTK + packaging), - # plus libudev-dev for the serialport crate's libudev-sys build (CAT rig - # control) — without it the Linux build fails resolving the `libudev` - # pkg-config package. + # plus the native libs our crates link against on Linux: + # - libudev-dev: serialport's libudev-sys (CAT rig control) + # - libasound2-dev: alsa-sys (audio capture/playback) + # Without these the Linux build fails resolving the `libudev` / `alsa` + # pkg-config packages. - name: Install Linux dependencies if: runner.os == 'Linux' run: | @@ -180,6 +182,7 @@ jobs: librsvg2-dev \ libappindicator3-dev \ libudev-dev \ + libasound2-dev \ patchelf \ file \ build-essential \ From 5fe2f4e726bcb34aee4e441554e6dcb0e7b9048e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 18 Jun 2026 16:06:00 -0500 Subject: [PATCH 8/8] Desktop CI: fix hung apt step on ubuntu-latest The Linux desktop build hung indefinitely on "Install Linux dependencies" while the macOS/Windows builds finished in minutes. Two fixes: - ubuntu-latest is now Ubuntu 24.04, which dropped libappindicator3-dev. Switch to libayatana-appindicator3-dev (Tauri's current Linux prereq). - Harden apt against the classic CI hangs: DEBIAN_FRONTEND=noninteractive (suppress debconf prompts -y misses) and -o DPkg::Lock::Timeout=600 (wait up to 10 min for the dpkg lock instead of forever, then fail loudly). Also --no-install-recommends to keep the install lean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/desktop.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 5bea65f1..4fff0e1c 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -172,15 +172,27 @@ jobs: # - libasound2-dev: alsa-sys (audio capture/playback) # Without these the Linux build fails resolving the `libudev` / `alsa` # pkg-config packages. + # + # ubuntu-latest is Ubuntu 24.04, which dropped libappindicator3-dev in + # favour of the Ayatana fork — use libayatana-appindicator3-dev (Tauri's + # current documented prerequisite). + # + # DEBIAN_FRONTEND=noninteractive + DPkg::Lock::Timeout guard against the two + # ways this step hangs forever on hosted runners: a debconf prompt that -y + # doesn't suppress, and apt blocking on the dpkg lock held by a background + # apt-daily job (instead, wait up to 10 min then fail loudly). - name: Install Linux dependencies if: runner.os == 'Linux' + env: + DEBIAN_FRONTEND: noninteractive run: | - sudo apt-get update - sudo apt-get install -y \ + sudo apt-get update -o DPkg::Lock::Timeout=600 + sudo apt-get install -y --no-install-recommends \ + -o DPkg::Lock::Timeout=600 \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ librsvg2-dev \ - libappindicator3-dev \ + libayatana-appindicator3-dev \ libudev-dev \ libasound2-dev \ patchelf \