Compare commits

...

4 Commits

Author SHA1 Message Date
Cursor Agent
1dd4ff4ba1
fix(web): recover local settings boot when module fetch aborts
Lazy-load the desktop-settings page and its heavy sections so
loadWithRecovery can retry or reload after ERR_NETWORK_CHANGED.
Show the no-desktop alert immediately when the bridge is absent.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-03 21:07:41 +00:00
Claude
1c3d8c40fe
test(updater): narrow optional workflow jobs and format the OTA assertion
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFCmHk4zPyWpD9yY2C3yJ
2026-10-03 20:26:52 +00:00
Claude
80c56eef36
Merge remote-tracking branch 'origin/main' into cursor/ci-mobile-ota-soft-fail-85b8 2026-10-03 20:26:03 +00:00
Cursor Agent
1704d1cec9
ci: soft-fail mobile OTA when EXPO_TOKEN is missing
OTA publish is an infra helper; missing Expo credentials should not
fail the ci workflow or block evo-deploy after green Product CI.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-03 18:41:55 +00:00
5 changed files with 105 additions and 21 deletions

View File

@ -246,6 +246,7 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
@ -344,23 +345,30 @@ jobs:
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token
- name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
exit 1
{
echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash
run: |
set -euo pipefail
@ -377,25 +385,25 @@ jobs:
mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
- name: Verify mobile project
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check
- name: Publish production update
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile
run: |
set -euo pipefail

View File

@ -75,7 +75,11 @@ test("local settings open and save integrations without an app session", async (
});
test("ordinary browsers cannot use local settings", async ({ page }) => {
await page.goto("/desktop-settings");
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
await expect(async () => {
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
});

View File

@ -14,7 +14,6 @@ import {
showSessionUnavailable,
} from "./lib/session-gate";
import { IntegrationSetupPage } from "./pages/IntegrationSetup";
import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell";
@ -30,9 +29,18 @@ const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
);
const LocalSettingsPage = lazyWithRecovery("pages/LocalSettings", () =>
import("./pages/LocalSettings").then((module) => ({ default: module.LocalSettingsPage })),
);
export function App() {
if (window.location.pathname === LOCAL_SETTINGS_PAGE) return <LocalSettingsPage />;
if (window.location.pathname === LOCAL_SETTINGS_PAGE) {
return (
<Suspense fallback={<div className="h-full bg-background" data-rakazo-app-state="loading" />}>
<LocalSettingsPage />
</Suspense>
);
}
return <SessionApp />;
}

View File

@ -1,20 +1,31 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { useEffect, useState } from "react";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { Suspense, useEffect, useState } from "react";
import { desktopBridge } from "../lib/desktop";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { rpc } from "../lib/rpc";
import { ModelSettingsOverlay } from "./ModelSettingsOverlay";
const IntegrationSetup = lazyWithRecovery("integrations/IntegrationSetup", () =>
import("../components/integrations/IntegrationSetup").then((module) => ({
default: module.IntegrationSetup,
})),
);
const ModelSettingsOverlay = lazyWithRecovery("pages/ModelSettingsOverlay", () =>
import("./ModelSettingsOverlay").then((module) => ({ default: module.ModelSettingsOverlay })),
);
export function LocalSettingsPage() {
const [section, setSection] = useState<"models" | "integrations" | null>(null);
const hasLocalBridge = Boolean(desktopBridge()?.localSettings);
const [ready, setReady] = useState(false);
const [error, setError] = useState(false);
const [error, setError] = useState(!hasLocalBridge);
useEffect(() => {
if (!hasLocalBridge) return;
void rpc.integrationSetup
.get()
.then(() => setReady(true))
.catch(() => setError(true));
}, []);
}, [hasLocalBridge]);
return (
<main className="h-full overflow-auto bg-background px-6 py-12">
<div className="mx-auto max-w-xl space-y-6">
@ -43,9 +54,15 @@ export function LocalSettingsPage() {
</Button>
</nav>
{section === "models" ? (
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
<Suspense fallback={null}>
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
</Suspense>
) : null}
{section === "integrations" ? (
<Suspense fallback={null}>
<IntegrationSetup serverSetup managedOnly />
</Suspense>
) : null}
{section === "integrations" ? <IntegrationSetup serverSetup managedOnly /> : null}
</>
) : null}
</div>

View File

@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowText = readFileSync(
path.resolve(import.meta.dirname, "../../../.github/workflows/ci.yml"),
"utf8",
);
interface WorkflowJob {
if?: string;
needs?: string[];
"continue-on-error"?: boolean;
}
const workflow = parse(workflowText) as {
jobs: Record<string, WorkflowJob>;
};
describe("ci workflow merge gate", () => {
it("aggregates only product jobs in Product CI", () => {
const productGate = workflow.jobs["product-gate"];
expect(productGate?.needs).toEqual([
"lint",
"check",
"build",
"test",
"test-integration",
"test-e2e",
]);
expect(productGate?.if).toBe("always()");
});
it("soft-fails mobile OTA so missing Expo credentials do not fail the workflow", () => {
const mobile = workflow.jobs["publish-mobile-update"];
expect(mobile?.["continue-on-error"]).toBe(true);
expect(workflowText).toContain('echo "ready=false" >> "$GITHUB_OUTPUT"');
expect(workflowText).not.toMatch(
/Check Expo automation token[\s\S]*?exit 1[\s\S]*?Require Expo/,
);
expect(workflowText).not.toContain(
"Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project.",
);
expect(workflowText).toContain("steps.expo_token.outputs.ready == 'true'");
});
});