Skip to content

Sync feedback: Mobile runtime, clipboard, and model fixes #1409

Sync feedback: Mobile runtime, clipboard, and model fixes

Sync feedback: Mobile runtime, clipboard, and model fixes #1409

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
# ONE job, not four.
#
# lint, typecheck, architecture and test used to be separate jobs, and the only thing that made them
# separate was the gate at the end - all four repeated the SAME seven setup steps (checkout, pro
# submodule, node, java, two shared checkouts, install). That duplication was not just slow, it was
# the bug: the shared-provisioning step was wrong in all four copies at once, and one of the four
# (lint) had silently lost its `env: PRO_SUBMODULE_PAT`, so it never provisioned shared at all.
#
# Setup happens once here and every gate runs as a step against it. Each gate carries
# `if: ${{ !cancelled() && steps.install.outcome == 'success' }}`, so a red gate does NOT hide the
# ones after it - the whole list still reports, which is the one thing the four separate checks were
# genuinely good at. The trade-off is wall-clock: the gates no longer run in parallel.
jobs:
ci:
runs-on: macos-latest
env:
# Exposed at the job level so the step `if:` below can read it: the `secrets` context
# is NOT allowed inside an `if:` condition (GitHub rejects the whole workflow at
# startup — a 0-second failure), but the `env` context IS. This one line being an
# `if: ${{ secrets.* }}` is what took the entire CI pipeline down.
PRO_SUBMODULE_PAT: ${{ secrets.PRO_SUBMODULE_PAT }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check out pro submodule (private; skipped on open-core forks)
# When the PRO_SUBMODULE_PAT secret is present (this org's CI), pull the private pro/
# submodule so the pro-dependent suites run against the REAL package — a green run then
# actually exercises pro, not a stub. It also matters to the other gates: tsc sees
# __tests__/pro/* importing @offgrid/pro/* (TS2307 without it), and knip/depcruise need it
# or their reachability graph is incomplete and reports false orphans. Forks without the
# secret skip this: proExists=false and jest runs the open-core suite instead.
if: ${{ env.PRO_SUBMODULE_PAT != '' }}
env:
PRO_PAT: ${{ env.PRO_SUBMODULE_PAT }}
run: |
git config --global url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf "https://github.com/"
git submodule update --init --recursive pro
git config --global --unset url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf || true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Node 26, pinned by the TEST gate and now shared by all of them. The RAG/knowledge-base
# suites run a REAL in-memory database via node's built-in `node:sqlite`
# (__tests__/harness/sqliteFake.ts). That module is absent on Node 20 and flag-gated on
# Node 22. On Node 24 it loads but its native teardown SEGFAULTs the process under jest's
# --forceExit (exit 139) — reproduced locally: the full suite exits 139 on Node 24 and
# exits 0 on Node 26. Node 26's node:sqlite fixes that teardown crash.
node-version: '26'
cache: 'npm'
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
# @offgrid/sync and @offgrid/rag are file: dependencies on the SIBLING shared monorepo
# ("file:../shared/packages/sync", "file:../shared/packages/rag"). They arrived with the sync
# work and nothing in CI ever provisioned them, so every gate failed on the same missing module
# wearing different hats: TS2307 in typecheck, 26 no-phantom-deps errors in the architecture
# gate, and 260 jest suites unable to resolve <workspace>/../shared/packages/sync/src/index.ts.
#
# actions/checkout refuses a path outside the workspace, so it lands inside and is moved up one
# level - which is exactly where the file: specifiers and jest's moduleNameMapper both point.
#
# Matching branch first, main as the fallback: a PR that changes the app and the shared package
# together has to be tested against the package it expects, not against main's copy.
- name: Check out the shared monorepo
if: ${{ env.PRO_SUBMODULE_PAT != '' }}
id: shared_branch
continue-on-error: true
uses: actions/checkout@v4
with:
repository: off-grid-ai/shared
token: ${{ env.PRO_SUBMODULE_PAT }}
path: _shared
ref: ${{ github.head_ref || github.ref_name }}
persist-credentials: false
- name: Fall back to shared main
if: ${{ env.PRO_SUBMODULE_PAT != '' && steps.shared_branch.outcome != 'success' }}
uses: actions/checkout@v4
with:
repository: off-grid-ai/shared
token: ${{ env.PRO_SUBMODULE_PAT }}
path: _shared
persist-credentials: false
- name: Put shared beside this checkout
if: ${{ env.PRO_SUBMODULE_PAT != '' }}
run: |
rm -rf ../shared
mv _shared ../shared
# Install at the WORKSPACE ROOT, not inside packages/sync. shared is an npm-workspaces
# monorepo: the lock file and the build tool (tsup) live at the root, and packages/sync
# declares neither. Installing inside the member failed `npm ci` (no lock file there), fell
# through to `npm install`, pulled the member's five runtime deps, and left the build to die
# on `sh: 1: tsup: not found` (exit 127) — which took every gate down with it.
#
# A LOCKED install, and no fallback. `npm ci || npm install` was here to survive a drifted lock, but
# that just moves the failure: an unlocked install resolves a different graph and the later gates run
# against dependencies nobody committed. The drift it papered over was real (five packages committed to
# shared without regenerating its lock) and is fixed at the source; if it recurs, this should stop.
npm --prefix ../shared ci
# Both packages this app consumes, because npm resolves each file: dep to its dist/ (jest
# reads src/, but tsc and metro read dist/). rag was never built at all before.
npm --prefix ../shared/packages/sync run build
npm --prefix ../shared/packages/rag run build
- name: Install dependencies
id: install
run: npm ci
- name: Install SwiftLint
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: brew install swiftlint
# ── Gates. Every one runs even when an earlier one failed, so a single red gate cannot hide
# the rest; the job still fails if any of them do.
# ESLint ONLY - deliberately not `npm run lint`, which chains ./gradlew :app:lintDebug.
#
# Measured on run 31020019489: eslint finished in 36 SECONDS (15:24:59 -> 15:25:35); Android Lint then ran
# for 68 MINUTES, cold-configuring every React Native native module on a macOS runner
# (react-native-fs, background-downloader, documents/picker, audio-api, ...). It was 68 of the job's 90
# minutes - the whole reason a mobile PR took an hour and a half to report.
#
# Same call, and same reason, as the Android BUILD already documented below: the hosted runner is the wrong
# place for gradle work on this project. Android Lint stays a LOCAL pre-merge gate, run alongside the build
# it shares all that configuration cost with:
# npm run lint:android (cd android && ./gradlew :app:lintDebug)
# Android unit tests still run here (:app:testDebugUnitTest, ~2 min) - it is lint's full-graph configure
# that is pathological, not gradle itself.
- name: Lint (eslint)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npx eslint .
- name: Type check
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npx tsc --noEmit
# Standing gates: dependency-cruiser (layering, engine DIP, import cycles, orphans — 0
# violations, no baseline) + knip (dead files/exports/types/deps — 0 issues). Both fail on
# anything NEW.
- name: Dependency-cruiser (architecture gate)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm run depcruise
- name: knip (dead-code gate)
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npm run knip
- name: Run Jest tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: npx jest --coverage --forceExit --runInBand
- name: Install Android NDK
# Configuring the native modules (op-sqlite etc.) needs the pinned NDK; the macOS
# runner doesn't ship it, so the android test task failed at configure with
# "Failed to install ndk;...". Install the version android/build.gradle forces.
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: |
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
yes | "$SDKMANAGER" "ndk;27.1.12297006" >/dev/null
- name: Run Android tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: cd android && ./gradlew :app:testDebugUnitTest --rerun-tasks
- name: Run iOS tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: |
cd ios && xcodebuild test \
-workspace OffgridMobile.xcworkspace \
-scheme OffgridMobile \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:OffgridMobileTests \
2>&1 | (xcpretty 2>/dev/null || cat)
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: false
- name: Upload iOS test results
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: ~/Library/Developer/Xcode/DerivedData/**/Logs/Test/*.xcresult
if-no-files-found: ignore
- name: Upload Android test results
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-results
path: android/app/build/reports/tests/
if-no-files-found: ignore
# NOTE: the Android build is NOT run in CI — it is a LOCAL pre-merge gate instead (the hosted
# runner repeatedly hung for 3+ hours on the native C++ builds and burned hours). Run it locally
# before merging any change that touches native/Android/gradle/deps:
# cd android && ./gradlew assembleDebug assembleRelease
# (see rules.md → On-device testing & verification). Docs-only / JS-only PRs don't need it.
# NOTE: SonarCloud runs via Automatic Analysis (SonarCloud-side, on the public core project) —
# no CI job. A CI scan would only add coverage import, which Codecov already does, so it'd be
# redundant complexity. Pro is scanned locally by eslint-plugin-sonarjs (free, private, no leak).
# See docs/GAPS_BACKLOG.md.