From 62e0ccb79d342b06b479308ab5f256aa2ff6a35f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 06:56:11 -0500 Subject: [PATCH 1/4] Stabilize browser test runner and related types - Run browser tests file-by-file with timeouts - Tighten a few web types and exports - Fix GitManager test clone target for main branch --- apps/server/src/git/Layers/GitManager.test.ts | 2 +- apps/web/package.json | 2 +- apps/web/src/components/ChatView.tsx | 1 - apps/web/src/components/Sidebar.tsx | 30 +++-- .../src/components/chat/ChangedFilesTree.tsx | 2 +- .../components/home/ChatHomeEmptyState.tsx | 2 +- .../components/home/HomeProviderStatus.tsx | 2 +- apps/web/src/routes/_chat.$threadId.tsx | 1 - apps/web/src/terminalStateStore.ts | 2 +- scripts/run-browser-tests.mjs | 113 ++++++++++++++++++ 10 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 scripts/run-browser-tests.mjs diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 36587167b..740c1f535 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -791,7 +791,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["checkout", "-b", "feature/rebase-before-commit"]); const updaterDir = yield* makeTempDir("okcode-git-manager-updater-"); - yield* runGit(updaterDir, ["clone", remoteDir, "."]); + yield* runGit(updaterDir, ["clone", "--branch", "main", remoteDir, "."]); yield* runGit(updaterDir, ["config", "user.email", "test@example.com"]); yield* runGit(updaterDir, ["config", "user.name", "Test User"]); fs.writeFileSync(path.join(updaterDir, "base.txt"), "remote main update\n"); diff --git a/apps/web/package.json b/apps/web/package.json index cca7dfc35..bd930c488 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", - "test:browser": "vitest run --config vitest.browser.config.ts", + "test:browser": "node ../../scripts/run-browser-tests.mjs apps/web/vitest.browser.config.ts", "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bd52ad92c..c37292423 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -16,7 +16,6 @@ import { type ResolvedKeybindingsConfig, type ServerProviderStatus, type ThreadId, - type TurnId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bac1c4514..7afd6f206 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -61,7 +61,11 @@ import { readNativeApi } from "../nativeApi"; import { resolveServerHttpOrigin } from "../lib/runtimeBridge"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { + selectThreadTerminalState, + type ThreadTerminalState, + useTerminalStateStore, +} from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { getArm64IntelBuildWarningDescription, @@ -370,7 +374,7 @@ interface MemoizedThreadRowProps { routeThreadId: ThreadIdType | null; pColor: ReturnType; prByThreadId: Map; - terminalStateByThreadId: Record; + terminalStateByThreadId: Record; orderedProjectThreadIds: ThreadIdType[]; selectedThreadIds: ReadonlySet; editingThreadId: ThreadIdType | null; @@ -380,12 +384,18 @@ interface MemoizedThreadRowProps { setDraftTitle: (title: string) => void; commitEditing: () => Promise | void; cancelEditing: () => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigate: (...args: any[]) => any; + navigate: ReturnType; clearSelection: () => void; setSelectionAnchor: (threadId: ThreadIdType) => void; - handleThreadClick: (event: MouseEvent, threadId: ThreadIdType, orderedProjectThreadIds: readonly ThreadIdType[]) => void; - handleThreadContextMenu: (threadId: ThreadIdType, position: { x: number; y: number }) => Promise; + handleThreadClick: ( + event: MouseEvent, + threadId: ThreadIdType, + orderedProjectThreadIds: readonly ThreadIdType[], + ) => void; + handleThreadContextMenu: ( + threadId: ThreadIdType, + position: { x: number; y: number }, + ) => Promise; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; formatRelativeTimeFn: (iso: string) => string; @@ -539,9 +549,7 @@ const MemoizedThreadRow = memo( title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > - + )} - + {renderedThreads.map((thread) => ( void; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 0ecbd87cc..864fdc0e2 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -16,7 +16,6 @@ import { useCodeViewerStore } from "../codeViewerStore"; import { usePreviewStateStore } from "../previewStateStore"; import { useSimulationViewerStore } from "../simulationViewerStore"; import { useMutuallyExclusivePanels } from "../mutuallyExclusivePanels"; -import { useMediaQuery } from "../hooks/useMediaQuery"; import { useClientMode } from "../hooks/useClientMode"; import { useStore } from "../store"; import { Sheet, SheetPopup } from "../components/ui/sheet"; diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index c245607cd..1f68638fa 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -15,7 +15,7 @@ import { type ThreadTerminalGroup, } from "./types"; -interface ThreadTerminalState { +export interface ThreadTerminalState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; diff --git a/scripts/run-browser-tests.mjs b/scripts/run-browser-tests.mjs new file mode 100644 index 000000000..4c3cc0c31 --- /dev/null +++ b/scripts/run-browser-tests.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { readdirSync, statSync } from "node:fs"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const BROWSER_TEST_SUFFIX = ".browser.tsx"; + +function listBrowserTests(rootDir) { + const files = []; + const visit = (currentDir) => { + for (const entry of readdirSync(currentDir)) { + const absolutePath = path.join(currentDir, entry); + const stats = statSync(absolutePath); + if (stats.isDirectory()) { + visit(absolutePath); + continue; + } + if (stats.isFile() && absolutePath.endsWith(BROWSER_TEST_SUFFIX)) { + files.push(absolutePath); + } + } + }; + + visit(rootDir); + return files.sort(); +} + +function runTestFile({ configPath, filePath, cwd, timeoutMs }) { + return new Promise((resolve) => { + const vitestBin = path.join(cwd, "node_modules", "vitest", "vitest.mjs"); + const args = [vitestBin, "run", "--config", configPath, filePath]; + const relativeFile = path.relative(cwd, filePath); + + console.log(`\n[browser] Running ${relativeFile}`); + + const child = spawn(process.execPath, args, { + cwd, + stdio: "inherit", + env: process.env, + }); + + const timeout = setTimeout(() => { + console.error(`[browser] Timed out after ${timeoutMs}ms: ${relativeFile}`); + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5_000).unref(); + resolve({ + ok: false, + filePath, + code: null, + signal: "SIGTERM", + timedOut: true, + }); + }, timeoutMs); + + child.on("exit", (code, signal) => { + clearTimeout(timeout); + resolve({ + ok: code === 0, + filePath, + code, + signal, + timedOut: false, + }); + }); + }); +} + +async function main() { + const cwd = process.cwd(); + const configArg = process.argv[2] ?? "apps/web/vitest.browser.config.ts"; + const timeoutArg = process.argv[3]; + const timeoutMs = + timeoutArg && Number.isFinite(Number(timeoutArg)) ? Number(timeoutArg) : DEFAULT_TIMEOUT_MS; + + const configPath = path.resolve(cwd, configArg); + const browserTestRoot = path.resolve(path.dirname(configPath), "src", "components"); + const browserTests = listBrowserTests(browserTestRoot); + + if (browserTests.length === 0) { + console.log("[browser] No browser test files found."); + return; + } + + console.log(`[browser] Found ${browserTests.length} browser test files.`); + + for (const filePath of browserTests) { + const result = await runTestFile({ + configPath, + filePath, + cwd, + timeoutMs, + }); + + if (!result.ok) { + const relativeFile = path.relative(cwd, result.filePath); + if (result.timedOut) { + process.exitCode = 1; + throw new Error(`Browser test timed out: ${relativeFile}`); + } + process.exitCode = result.code ?? 1; + throw new Error( + `Browser test failed: ${relativeFile} (code=${String(result.code)}, signal=${String(result.signal)})`, + ); + } + } + + console.log("\n[browser] All browser test files passed."); +} + +await main(); From eb7f42b347f2efd787c450436ccf4fb630001f3f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 07:01:38 -0500 Subject: [PATCH 2/4] Expose build metadata across server and web - Add shared build metadata schema and server-side build info - Surface version, channel, commit, and timestamp in settings and doctor output - Thread build info through server config and web Vite env --- apps/server/src/buildInfo.ts | 51 ++++++++++++++++++++ apps/server/src/doctor.ts | 9 ++++ apps/server/src/main.ts | 2 + apps/server/src/wsServer.ts | 2 + apps/web/package.json | 2 +- apps/web/src/branding.ts | 64 ++++++++++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 33 +++++++++++-- apps/web/src/vite-env.d.ts | 3 ++ apps/web/vite.config.ts | 7 +++ packages/contracts/src/buildInfo.ts | 19 ++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/server.ts | 2 + 12 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/buildInfo.ts create mode 100644 packages/contracts/src/buildInfo.ts diff --git a/apps/server/src/buildInfo.ts b/apps/server/src/buildInfo.ts new file mode 100644 index 000000000..c21ef8d65 --- /dev/null +++ b/apps/server/src/buildInfo.ts @@ -0,0 +1,51 @@ +import type { BuildChannel, BuildMetadata, BuildSurface } from "@okcode/contracts"; +import { version as serverVersion } from "../package.json" with { type: "json" }; + +const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const COMMIT_HASH_DISPLAY_LENGTH = 12; + +function normalizeCommitHash(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!COMMIT_HASH_PATTERN.test(trimmed)) { + return null; + } + + return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); +} + +export function resolveBuildChannel(version: string): BuildChannel { + return version.includes("-") ? "prerelease" : "stable"; +} + +function resolveBuildTimestamp(): string { + const timestamp = process.env.OKCODE_BUILD_TIMESTAMP?.trim(); + return timestamp && timestamp.length > 0 ? timestamp : new Date().toISOString(); +} + +export function createBuildInfo(input: { + readonly version: string; + readonly surface: BuildSurface; + readonly platform: string; + readonly arch: string; +}): BuildMetadata { + return { + version: input.version, + commitHash: normalizeCommitHash(process.env.OKCODE_COMMIT_HASH ?? process.env.GITHUB_SHA), + platform: input.platform, + arch: input.arch, + channel: resolveBuildChannel(input.version), + buildTimestamp: resolveBuildTimestamp(), + surface: input.surface, + }; +} + +export const serverBuildInfo = createBuildInfo({ + version: serverVersion, + surface: "server", + platform: process.platform, + arch: process.arch, +}); diff --git a/apps/server/src/doctor.ts b/apps/server/src/doctor.ts index f0913b1f6..a949bc103 100644 --- a/apps/server/src/doctor.ts +++ b/apps/server/src/doctor.ts @@ -15,6 +15,7 @@ import { } from "./provider/Layers/ProviderHealth"; import type { ServerProviderStatus } from "@okcode/contracts"; import { fixPath } from "./os-jank"; +import { serverBuildInfo } from "./buildInfo"; const STATUS_ICONS: Record = { ready: "\u2705", @@ -54,6 +55,14 @@ const doctorProgram = Effect.gen(function* () { console.log("OK Code Doctor"); console.log("=============="); console.log(""); + console.log(`Version: ${serverBuildInfo.version}`); + console.log( + `Surface: ${serverBuildInfo.surface} (${serverBuildInfo.platform}/${serverBuildInfo.arch})`, + ); + console.log(`Channel: ${serverBuildInfo.channel}`); + console.log(`Commit: ${serverBuildInfo.commitHash ?? "unknown"}`); + console.log(`Built: ${serverBuildInfo.buildTimestamp}`); + console.log(""); console.log("Checking provider health..."); const statuses = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index a48ac4b58..ad7c34f8c 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -25,6 +25,7 @@ import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { doctorCmd } from "./doctor"; +import { serverBuildInfo } from "./buildInfo"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -235,6 +236,7 @@ const makeServerProgram = (input: CliInput) => ...safeConfig, devUrl: devUrl?.toString(), authEnabled: Boolean(authToken), + buildInfo: serverBuildInfo, }); if (!config.noBrowser) { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ec7768d5d..cb509dbcd 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -94,6 +94,7 @@ import { SkillService } from "./skills/SkillService.ts"; import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; +import { serverBuildInfo } from "./buildInfo"; /** * Returns true if `a` is a strictly higher semver than `b`. @@ -1420,6 +1421,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< issues: keybindingsConfig.issues, providers: providerStatuses, availableEditors, + buildInfo: serverBuildInfo, }; case WS_METHODS.serverCheckUpdate: { diff --git a/apps/web/package.json b/apps/web/package.json index bd930c488..2ebb7a1c4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", - "test:browser": "node ../../scripts/run-browser-tests.mjs apps/web/vitest.browser.config.ts", + "test:browser": "node ../../scripts/run-browser-tests.mjs vitest.browser.config.ts", "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 1c76615fd..898749c2d 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,3 +1,4 @@ +import type { BuildMetadata, BuildSurface } from "@okcode/contracts"; import { APP_BASE_NAME } from "@okcode/shared/brand"; export { APP_BASE_NAME }; @@ -7,3 +8,66 @@ export const APP_DISPLAY_NAME = APP_STAGE_LABEL ? `${APP_BASE_NAME} (${APP_STAGE_LABEL})` : APP_BASE_NAME; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_COMMIT_HASH = import.meta.env.APP_COMMIT_HASH.trim() || null; + +function resolveAppSurface(): BuildSurface { + if (typeof window !== "undefined") { + if (window.mobileBridge) return "mobile"; + if (window.desktopBridge) return "desktop"; + } + return "web"; +} + +function resolveBrowserPlatform(): string { + if (typeof navigator === "undefined") { + return "unknown"; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes("iphone") || userAgent.includes("ipad") || userAgent.includes("ipod")) { + return "ios"; + } + if (userAgent.includes("android")) { + return "android"; + } + + const navigatorWithClientHints = navigator as Navigator & { + readonly userAgentData?: { readonly platform?: string; readonly architecture?: string }; + }; + return ( + navigatorWithClientHints.userAgentData?.platform?.trim() || navigator.platform || "unknown" + ); +} + +function resolveBrowserArch(): string { + if (typeof navigator === "undefined") { + return "unknown"; + } + + const navigatorWithClientHints = navigator as Navigator & { + readonly userAgentData?: { readonly platform?: string; readonly architecture?: string }; + }; + const hintedArch = navigatorWithClientHints.userAgentData?.architecture?.trim(); + if (hintedArch) { + return hintedArch; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes("arm64") || userAgent.includes("aarch64")) { + return "arm64"; + } + if (userAgent.includes("x86_64") || userAgent.includes("win64") || userAgent.includes("x64")) { + return "x64"; + } + return "unknown"; +} + +export const APP_BUILD_INFO: BuildMetadata = { + version: APP_VERSION, + commitHash: APP_COMMIT_HASH, + platform: resolveBrowserPlatform(), + arch: resolveBrowserArch(), + channel: import.meta.env.DEV ? "development" : import.meta.env.APP_RELEASE_CHANNEL, + buildTimestamp: import.meta.env.APP_BUILD_TIMESTAMP, + surface: resolveAppSurface(), +}; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 35b610448..2824dd5b2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -23,7 +23,7 @@ import { patchCustomModels, useAppSettings, } from "../appSettings"; -import { APP_VERSION } from "../branding"; +import { APP_BUILD_INFO } from "../branding"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; @@ -1872,10 +1872,35 @@ function SettingsRouteView() { /> {APP_VERSION} +
+
+
+ App +
+ + {`${APP_BUILD_INFO.version} · ${APP_BUILD_INFO.surface} · ${APP_BUILD_INFO.platform}/${APP_BUILD_INFO.arch}`} + + + {`${APP_BUILD_INFO.channel} · ${APP_BUILD_INFO.commitHash ?? "unknown"} · ${APP_BUILD_INFO.buildTimestamp}`} + +
+ {serverConfigQuery.data?.buildInfo ? ( +
+
+ Server +
+ + {`${serverConfigQuery.data.buildInfo.version} · ${serverConfigQuery.data.buildInfo.surface} · ${serverConfigQuery.data.buildInfo.platform}/${serverConfigQuery.data.buildInfo.arch}`} + + + {`${serverConfigQuery.data.buildInfo.channel} · ${serverConfigQuery.data.buildInfo.commitHash ?? "unknown"} · ${serverConfigQuery.data.buildInfo.buildTimestamp}`} + +
+ ) : null} +
} /> diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index e59ae75f5..38f702d05 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -4,6 +4,9 @@ import type { NativeApi, DesktopBridge, MobileBridge } from "@okcode/contracts"; interface ImportMetaEnv { readonly APP_VERSION: string; + readonly APP_COMMIT_HASH: string; + readonly APP_BUILD_TIMESTAMP: string; + readonly APP_RELEASE_CHANNEL: "stable" | "prerelease" | "development"; } interface ImportMeta { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd33183bc..65de52649 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -14,6 +14,10 @@ const buildSourcemap = : sourcemapEnv === "hidden" ? "hidden" : true; +const buildTimestamp = process.env.OKCODE_BUILD_TIMESTAMP ?? new Date().toISOString(); +const releaseChannel = + process.env.OKCODE_RELEASE_CHANNEL ?? (pkg.version.includes("-") ? "prerelease" : "stable"); +const commitHash = process.env.OKCODE_COMMIT_HASH ?? process.env.GITHUB_SHA ?? ""; export default defineConfig({ plugins: [ @@ -36,6 +40,9 @@ export default defineConfig({ // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), + "import.meta.env.APP_COMMIT_HASH": JSON.stringify(commitHash), + "import.meta.env.APP_BUILD_TIMESTAMP": JSON.stringify(buildTimestamp), + "import.meta.env.APP_RELEASE_CHANNEL": JSON.stringify(releaseChannel), }, resolve: { tsconfigPaths: true, diff --git a/packages/contracts/src/buildInfo.ts b/packages/contracts/src/buildInfo.ts new file mode 100644 index 000000000..2857fc29f --- /dev/null +++ b/packages/contracts/src/buildInfo.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; + +export const BuildSurface = Schema.Literals(["web", "desktop", "mobile", "server", "cli"]); +export type BuildSurface = typeof BuildSurface.Type; + +export const BuildChannel = Schema.Literals(["stable", "prerelease", "development"]); +export type BuildChannel = typeof BuildChannel.Type; + +export const BuildMetadata = Schema.Struct({ + version: TrimmedNonEmptyString, + commitHash: Schema.NullOr(TrimmedNonEmptyString), + platform: TrimmedNonEmptyString, + arch: TrimmedNonEmptyString, + channel: BuildChannel, + buildTimestamp: IsoDateTime, + surface: BuildSurface, +}); +export type BuildMetadata = typeof BuildMetadata.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 225479791..537fef6b5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./buildInfo"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8502bd2de..8ccdb2671 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { BuildMetadata } from "./buildInfo"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; @@ -52,6 +53,7 @@ export const ServerConfig = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, availableEditors: Schema.Array(EditorId), + buildInfo: Schema.optional(BuildMetadata), }); export type ServerConfig = typeof ServerConfig.Type; From ca9f643c43f4e4dbcf6dd3f5485aad5e371ca3a3 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 07:03:37 -0500 Subject: [PATCH 3/4] Stabilize release train and split Intel compatibility build - Move the main release workflow to a coordinated stable train - Add a separate macOS x64 compatibility workflow - Refresh release docs for the new iOS, CLI, and desktop flow --- .github/workflows/release-intel-compat.yml | 67 +++ .github/workflows/release-ios.yml | 219 +------ .github/workflows/release.yml | 406 ++++++++++--- README.md | 21 +- docs/release.md | 633 ++++----------------- docs/releases/README.md | 214 +------ 6 files changed, 531 insertions(+), 1029 deletions(-) create mode 100644 .github/workflows/release-intel-compat.yml diff --git a/.github/workflows/release-intel-compat.yml b/.github/workflows/release-intel-compat.yml new file mode 100644 index 000000000..4a88a3805 --- /dev/null +++ b/.github/workflows/release-intel-compat.yml @@ -0,0 +1,67 @@ +name: Desktop Intel Compatibility + +on: + workflow_dispatch: + inputs: + version: + description: "Version to stamp into the Intel compatibility build" + required: true + type: string + +permissions: + contents: read + +jobs: + macos_intel_compat: + name: macOS x64 compatibility build + runs-on: macos-13 + env: + OKCODE_COMMIT_HASH: ${{ github.sha }} + OKCODE_BUILD_TIMESTAMP: ${{ github.event.inputs.version }} + OKCODE_RELEASE_CHANNEL: prerelease + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ github.event.inputs.version }}" + + - name: Build Intel desktop artifact + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: > + bun run dist:desktop:artifact -- + --platform mac + --target dmg + --arch x64 + --build-version "${{ github.event.inputs.version }}" + --signed + --require-signed + --verbose + + - name: Upload Intel compatibility artifact + uses: actions/upload-artifact@v7 + with: + name: desktop-mac-x64-compat + path: | + release/*.dmg + release/*.zip + release/latest-mac*.yml + if-no-files-found: error diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index d9f6631bc..7d83b0eef 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -1,9 +1,6 @@ -name: Release iOS +name: iOS Release Dry Run on: - push: - tags: - - "v*.*.*" workflow_dispatch: inputs: version: @@ -15,215 +12,11 @@ permissions: contents: read jobs: - preflight: - name: Preflight + guidance: + name: Coordinated train guidance runs-on: ubuntu-24.04 - outputs: - version: ${{ steps.release_meta.outputs.version }} - tag: ${{ steps.release_meta.outputs.tag }} - is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} - ref: ${{ github.sha }} steps: - - name: Checkout - uses: actions/checkout@v6 - - - id: release_meta - name: Resolve release version - shell: bash - run: | - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${{ github.event.inputs.version }}" - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - fi - - build-ios: - name: Build & Upload to TestFlight - needs: [preflight] - runs-on: macos-14 - env: - RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" - - - name: Update iOS version in Xcode project - run: node scripts/update-ios-version.ts "$RELEASE_VERSION" --build-number "$GITHUB_RUN_NUMBER" - - - name: Build mobile web bundle - run: bun run --cwd apps/mobile build - - - name: Sync Capacitor iOS - run: bunx cap sync ios --deployment - working-directory: apps/mobile - - - name: Install Apple certificate and provisioning profile - env: - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - run: | - set -euo pipefail - - # Validate required secrets - for secret_name in IOS_CERTIFICATE_P12 IOS_CERTIFICATE_PASSWORD IOS_PROVISIONING_PROFILE; do - if [[ -z "${!secret_name}" ]]; then - echo "Missing required secret: $secret_name" >&2 - exit 1 - fi - done - - # Create temporary keychain - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Import distribution certificate - CERT_PATH="$RUNNER_TEMP/certificate.p12" - echo "$IOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" - security import "$CERT_PATH" \ - -P "$IOS_CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 \ - -k "$KEYCHAIN_PATH" - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychain -d user -s "$KEYCHAIN_PATH" - - # Install provisioning profile - PROFILE_PATH="$RUNNER_TEMP/profile.mobileprovision" - echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ - - - name: Build iOS archive - env: - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - set -euo pipefail - - xcodebuild archive \ - -project apps/mobile/ios/App/App.xcodeproj \ - -scheme App \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath "$RUNNER_TEMP/App.xcarchive" \ - CODE_SIGN_STYLE=Manual \ - DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ - CODE_SIGN_IDENTITY="iPhone Distribution" \ - PROVISIONING_PROFILE_SPECIFIER="${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}" \ - -allowProvisioningUpdates \ - COMPILER_INDEX_STORE_ENABLE=NO - - - name: Generate ExportOptions.plist - env: - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - cat > "$RUNNER_TEMP/ExportOptions.plist" < - - - - method - app-store-connect - destination - upload - teamID - ${APPLE_TEAM_ID} - uploadSymbols - - signingStyle - manual - provisioningProfiles - - com.openknots.okcode.mobile - ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }} - - - - PLIST - - - name: Export IPA - run: | - set -euo pipefail - - xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/App.xcarchive" \ - -exportPath "$RUNNER_TEMP/export" \ - -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" - - - name: Write App Store Connect API key - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - run: | - set -euo pipefail - KEY_DIR="$HOME/private_keys" - mkdir -p "$KEY_DIR" - printf '%s' "$APPLE_API_KEY" > "$KEY_DIR/AuthKey_${APPLE_API_KEY_ID}.p8" - - - name: Upload to TestFlight - env: - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: | - set -euo pipefail - - IPA_FILE=$(find "$RUNNER_TEMP/export" -name "*.ipa" -print -quit) - if [[ -z "$IPA_FILE" ]]; then - echo "No IPA file found in export directory" >&2 - ls -la "$RUNNER_TEMP/export/" - exit 1 - fi - - echo "Uploading $IPA_FILE to TestFlight..." - - xcrun altool --upload-app \ - -f "$IPA_FILE" \ - -t ios \ - --apiKey "$APPLE_API_KEY_ID" \ - --apiIssuer "$APPLE_API_ISSUER" - - echo "Upload to TestFlight complete!" - - - name: Cleanup keychain - if: always() + - name: Explain release entrypoint run: | - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - if [[ -f "$KEYCHAIN_PATH" ]]; then - security delete-keychain "$KEYCHAIN_PATH" || true - fi - rm -f "$HOME/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" || true + echo "Use .github/workflows/release.yml for official tags and coordinated RC/stable releases." + echo "This workflow is reserved for manual iOS dry runs while stabilizing the TestFlight lane." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d71c51b00..55aaf4b8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release Desktop +name: Release on: push: @@ -10,48 +10,11 @@ on: description: "Release version (for example 1.2.3 or v1.2.3)" required: true type: string - mac_arm64_only: - description: "Apple Silicon (M-series) macOS only on Mac runners. Linux and Windows remain part of the full matrix when requested." - required: false - type: boolean - default: true permissions: contents: write jobs: - configure: - name: Configure build matrix - runs-on: ubuntu-24.04 - outputs: - matrix: ${{ steps.set.outputs.matrix }} - mac_arm64_only: ${{ steps.set.outputs.mac_arm64_only }} - steps: - - id: set - name: Select desktop targets - shell: bash - run: | - set -euo pipefail - mac_arm64_only="true" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - input="${{ github.event.inputs.mac_arm64_only }}" - if [[ "$input" == "false" ]]; then - mac_arm64_only="false" - fi - fi - echo "mac_arm64_only=$mac_arm64_only" >> "$GITHUB_OUTPUT" - - if [[ "$mac_arm64_only" == "true" ]]; then - matrix_json='[{"label":"macOS arm64","runner":"macos-14","platform":"mac","target":"dmg","arch":"arm64"}]' - else - matrix_json='[{"label":"macOS arm64","runner":"macos-14","platform":"mac","target":"dmg","arch":"arm64"},{"label":"Linux x64","runner":"ubuntu-24.04","platform":"linux","target":"AppImage","arch":"x64"},{"label":"Windows x64","runner":"windows-2022","platform":"win","target":"nsis","arch":"x64"}]' - fi - { - echo "matrix<> "$GITHUB_OUTPUT" - preflight: name: Preflight runs-on: ubuntu-24.04 @@ -60,6 +23,9 @@ jobs: tag: ${{ steps.release_meta.outputs.tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} + release_channel: ${{ steps.release_meta.outputs.release_channel }} + npm_tag: ${{ steps.release_meta.outputs.npm_tag }} + build_timestamp: ${{ steps.release_meta.outputs.build_timestamp }} ref: ${{ github.sha }} steps: - name: Checkout @@ -69,6 +35,8 @@ jobs: name: Resolve release version shell: bash run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then raw="${{ github.event.inputs.version }}" else @@ -81,14 +49,22 @@ jobs: exit 1 fi + build_timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "build_timestamp=$build_timestamp" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "make_latest=true" >> "$GITHUB_OUTPUT" + echo "release_channel=stable" >> "$GITHUB_OUTPUT" + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" else echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" + echo "release_channel=prerelease" >> "$GITHUB_OUTPUT" + echo "npm_tag=next" >> "$GITHUB_OUTPUT" fi - name: Setup Bun @@ -104,6 +80,12 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Install browser dependencies + run: bun run --cwd apps/web test:browser:install + + - name: Format check + run: bun run fmt:check + - name: Lint run: bun run lint @@ -113,17 +95,42 @@ jobs: - name: Test run: bun run test + - name: Browser test + run: bun run --cwd apps/web test:browser + + - name: Desktop smoke + run: bun run test:desktop-smoke + - name: Release smoke run: bun run release:smoke - build: - name: Build ${{ matrix.label }} - needs: [preflight, configure] + desktop_build: + name: Desktop ${{ matrix.label }} + needs: [preflight] runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - include: ${{ fromJson(needs.configure.outputs.matrix) }} + include: + - label: macOS arm64 + runner: macos-14 + platform: mac + target: dmg + arch: arm64 + - label: Linux x64 + runner: ubuntu-24.04 + platform: linux + target: AppImage + arch: x64 + - label: Windows x64 + runner: windows-2022 + platform: win + target: nsis + arch: x64 + env: + OKCODE_COMMIT_HASH: ${{ github.sha }} + OKCODE_BUILD_TIMESTAMP: ${{ needs.preflight.outputs.build_timestamp }} + OKCODE_RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} steps: - name: Checkout uses: actions/checkout@v6 @@ -163,6 +170,8 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} run: | + set -euo pipefail + args=( --platform "${{ matrix.platform }}" --target "${{ matrix.target }}" @@ -171,7 +180,7 @@ jobs: --verbose ) - has_all() { + require_values() { for value in "$@"; do if [[ -z "$value" ]]; then return 1 @@ -197,17 +206,15 @@ jobs: if (( ${#missing_mac_signing_secrets[@]} > 0 )); then echo "Missing required macOS signing/notarization secrets: ${missing_mac_signing_secrets[*]}" >&2 - echo "Refusing to publish an unsigned or unnotarized macOS release." >&2 exit 1 fi key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" - echo "macOS signing + notarization enabled." args+=(--signed --require-signed) elif [[ "${{ matrix.platform }}" == "win" ]]; then - if has_all \ + if ! require_values \ "$AZURE_TENANT_ID" \ "$AZURE_CLIENT_ID" \ "$AZURE_CLIENT_SECRET" \ @@ -215,17 +222,37 @@ jobs: "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + echo "Windows stable releases require Azure Trusted Signing secrets." >&2 + exit 1 fi - else - echo "Signing disabled for ${{ matrix.platform }}." + args+=(--signed --require-signed) fi bun run dist:desktop:artifact -- "${args[@]}" + - name: Validate packaged artifact outputs + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + + case "${{ matrix.platform }}" in + mac) + dmg=(release/*.dmg) + manifest=(release/latest-mac*.yml) + [[ ${#dmg[@]} -gt 0 ]] || { echo "Missing macOS DMG artifact" >&2; exit 1; } + [[ ${#manifest[@]} -gt 0 ]] || { echo "Missing macOS updater manifest" >&2; exit 1; } + ;; + linux) + appimage=(release/*.AppImage) + [[ ${#appimage[@]} -gt 0 ]] || { echo "Missing Linux AppImage artifact" >&2; exit 1; } + ;; + win) + installer=(release/*.exe) + [[ ${#installer[@]} -gt 0 ]] || { echo "Missing Windows installer artifact" >&2; exit 1; } + ;; + esac + - name: Collect release assets shell: bash run: | @@ -245,16 +272,261 @@ jobs: done done - - name: Upload build artifacts + - name: Upload desktop artifact bundle uses: actions/upload-artifact@v7 with: name: desktop-${{ matrix.platform }}-${{ matrix.arch }} path: release-publish/* if-no-files-found: error + ios_testflight: + name: iOS TestFlight + needs: [preflight] + runs-on: macos-14 + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + OKCODE_COMMIT_HASH: ${{ github.sha }} + OKCODE_BUILD_TIMESTAMP: ${{ needs.preflight.outputs.build_timestamp }} + OKCODE_RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" + + - name: Update iOS version in Xcode project + run: node scripts/update-ios-version.ts "$RELEASE_VERSION" --build-number "$GITHUB_RUN_NUMBER" + + - name: Build mobile web bundle + run: bun run --cwd apps/mobile build + + - name: Sync Capacitor iOS + run: bunx cap sync ios --deployment + working-directory: apps/mobile + + - name: Log iOS build metadata + run: | + echo "version=$RELEASE_VERSION" + echo "commit=$OKCODE_COMMIT_HASH" + echo "build_timestamp=$OKCODE_BUILD_TIMESTAMP" + echo "channel=$OKCODE_RELEASE_CHANNEL" + + - name: Install Apple certificate and provisioning profile + env: + IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + run: | + set -euo pipefail + for secret_name in IOS_CERTIFICATE_P12 IOS_CERTIFICATE_PASSWORD IOS_PROVISIONING_PROFILE; do + if [[ -z "${!secret_name}" ]]; then + echo "Missing required secret: $secret_name" >&2 + exit 1 + fi + done + + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$IOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" \ + -P "$IOS_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + PROFILE_PATH="$RUNNER_TEMP/profile.mobileprovision" + echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ + + - name: Simulator smoke build + run: | + set -euo pipefail + xcodebuild build \ + -project apps/mobile/ios/App/App.xcodeproj \ + -scheme App \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + COMPILER_INDEX_STORE_ENABLE=NO + + - name: Build iOS archive + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + + xcodebuild archive \ + -project apps/mobile/ios/App/App.xcodeproj \ + -scheme App \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/App.xcarchive" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_IDENTITY="iPhone Distribution" \ + PROVISIONING_PROFILE_SPECIFIER="${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}" \ + -allowProvisioningUpdates \ + COMPILER_INDEX_STORE_ENABLE=NO + + - name: Generate ExportOptions.plist + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + cat > "$RUNNER_TEMP/ExportOptions.plist" < + + + + method + app-store-connect + destination + upload + teamID + ${APPLE_TEAM_ID} + uploadSymbols + + signingStyle + manual + provisioningProfiles + + com.openknots.okcode.mobile + ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }} + + + + PLIST + + - name: Export IPA + run: | + set -euo pipefail + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/App.xcarchive" \ + -exportPath "$RUNNER_TEMP/export" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" + + - name: Write App Store Connect API key + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + run: | + set -euo pipefail + KEY_DIR="$HOME/private_keys" + mkdir -p "$KEY_DIR" + printf '%s' "$APPLE_API_KEY" > "$KEY_DIR/AuthKey_${APPLE_API_KEY_ID}.p8" + + - name: Upload to TestFlight + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + + IPA_FILE=$(find "$RUNNER_TEMP/export" -name "*.ipa" -print -quit) + if [[ -z "$IPA_FILE" ]]; then + echo "No IPA file found in export directory" >&2 + exit 1 + fi + + xcrun altool --upload-app \ + -f "$IPA_FILE" \ + -t ios \ + --apiKey "$APPLE_API_KEY_ID" \ + --apiIssuer "$APPLE_API_ISSUER" + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + if [[ -f "$KEYCHAIN_PATH" ]]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + rm -f "$HOME/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" || true + + publish_cli: + name: Publish CLI + needs: [preflight, desktop_build, ios_testflight] + runs-on: ubuntu-24.04 + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + OKCODE_COMMIT_HASH: ${{ github.sha }} + OKCODE_BUILD_TIMESTAMP: ${{ needs.preflight.outputs.build_timestamp }} + OKCODE_RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Build CLI package + run: bun run --cwd apps/server build + + - name: Verify npm pack + working-directory: apps/server + run: npm pack + + - name: Verify local CLI entrypoints + working-directory: apps/server + run: | + node dist/index.mjs --version + node dist/index.mjs --help >/dev/null + node dist/index.mjs doctor --help >/dev/null + + - name: Publish okcodes + run: > + node apps/server/scripts/cli.ts publish + --tag "${{ needs.preflight.outputs.npm_tag }}" + --app-version "${{ needs.preflight.outputs.version }}" + --verbose + + - name: Verify published CLI + run: | + npx --yes okcodes@${{ needs.preflight.outputs.version }} --version + npx --yes okcodes@${{ needs.preflight.outputs.version }} --help >/dev/null + release: name: Publish GitHub Release - needs: [preflight, build, configure] + needs: [preflight, desktop_build, ios_testflight, publish_cli] runs-on: ubuntu-24.04 steps: - name: Checkout @@ -267,34 +539,24 @@ jobs: with: node-version-file: package.json - - name: Download all desktop artifacts + - name: Download desktop artifacts uses: actions/download-artifact@v8 with: pattern: desktop-* merge-multiple: true path: release-assets - - name: Merge macOS updater manifests - run: | - set -euo pipefail - echo "Using latest-mac.yml as-is." - - - name: Stage documentation for release assets + - name: Stage release documentation env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} run: | set -euo pipefail - v="${RELEASE_VERSION}" - notes="docs/releases/v${v}.md" - manifest="docs/releases/v${v}/assets.md" - if [[ ! -f "$notes" ]]; then - echo "Missing release notes: $notes" >&2 - exit 1 - fi - if [[ ! -f "$manifest" ]]; then - echo "Missing asset manifest: $manifest" >&2 - exit 1 - fi + notes="docs/releases/v${RELEASE_VERSION}.md" + manifest="docs/releases/v${RELEASE_VERSION}/assets.md" + + [[ -f "$notes" ]] || { echo "Missing release notes: $notes" >&2; exit 1; } + [[ -f "$manifest" ]] || { echo "Missing asset manifest: $manifest" >&2; exit 1; } + cp CHANGELOG.md release-assets/okcode-CHANGELOG.md cp "$notes" release-assets/okcode-RELEASE-NOTES.md cp "$manifest" release-assets/okcode-ASSETS-MANIFEST.md diff --git a/README.md b/README.md index 82688f320..95df45bbf 100644 --- a/README.md +++ b/README.md @@ -185,16 +185,13 @@ Notes: ## 8) Release operations -Release is mostly driven by `.github/workflows/release.yml` and docs in `docs/releases`. +Release is driven by `.github/workflows/release.yml` and the canonical runbook in +[`docs/release.md`](/Users/buns/.okcode/worktrees/okcode/okcode-1c7a5554/docs/release.md). -- Trigger on tag push (`vX.Y.Z`) or `workflow_dispatch`. -- Preflight does `bun run lint`, `bun run typecheck`, `bun run test`, - and release smoke. -- Artifact build step executes `bun run dist:desktop:artifact`. -- Publishing requires release notes + asset manifest for the version. - -For a practical walkthrough, use the release playbook in -[`docs/releases/README.md`](/Users/buns/.okcode/worktrees/okcode/okcode-ddc899c0/docs/releases/README.md). +- The stable train now coordinates desktop, iOS TestFlight, and `okcodes` CLI on one version. +- Preflight runs format, lint, typecheck, tests, browser tests, desktop smoke, and release smoke. +- The separate Intel mac workflow is compatibility-only and non-blocking. +- Publishing still requires release notes and an asset manifest for the tagged version. ## 9) Extending the system (recommended pattern) @@ -216,8 +213,8 @@ For a practical walkthrough, use the release playbook in ## 11) FAQ -- **Why are x64 macOS artifacts sometimes absent from release matrix?** - The release workflow can be configured to build only Apple Silicon by default for `workflow_dispatch` with `mac_arm64_only=true`. +- **Why is Intel macOS not part of the stable gate?** + Apple Silicon is the blocking macOS target for the next release train. Intel builds run in a separate compatibility workflow until that lane is consistently green. - **Why strict release gates?** They prevent format/type drift from reaching published artifacts. - **Can I run release checks locally first?** @@ -236,7 +233,7 @@ For a practical walkthrough, use the release playbook in ### Pre-release hardening 1. Ensure release notes and asset manifest are prepared. -2. Confirm release matrix intent (`mac_arm64_only` expectation). +2. Confirm macOS, Windows, Linux, iOS TestFlight, and CLI release inputs are ready. 3. Confirm signing secrets availability for macOS/Windows targets. 4. Confirm `docs/releases/v.md` and `docs/releases/v/assets.md` exist. 5. Trigger release and monitor all jobs. diff --git a/docs/release.md b/docs/release.md index de29859c4..35c6515fd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,572 +1,157 @@ # Release Runbook -Canonical release process documentation for the OK Code project. +Canonical release process documentation for OK Code. -**Last updated:** 2026-03-31 - ---- - -## Table of contents - -1. [Overview](#overview) -2. [Prerequisites](#prerequisites) -3. [Version numbering](#version-numbering) -4. [Pre-release checklist](#pre-release-checklist) -5. [Cutting a release](#cutting-a-release) -6. [What the pipeline does](#what-the-pipeline-does) -7. [Release assets inventory](#release-assets-inventory) -8. [Post-release verification checklist](#post-release-verification-checklist) -9. [Hotfix releases](#hotfix-releases) -10. [Desktop auto-update notes](#desktop-auto-update-notes) -11. [Troubleshooting](#troubleshooting) - ---- +**Last updated:** 2026-04-05 ## Overview -A release of OK Code produces: - -- **Desktop installers** for macOS (arm64 + x64 DMG), Linux (x64 AppImage), and Windows (x64 NSIS). -- **GitHub Release** with all installer binaries, Electron updater metadata, and documentation attachments. -- **Post-release version bump** committed to `main` by a GitHub App bot. - -The **`okcodes` CLI npm package** is **not** published by CI; publish it manually when needed (see [npm publishing (CLI, manual)](#npm-publishing-cli-manual)). - -Releases follow Semantic Versioning and are triggered either by pushing a version tag (`v*.*.*`) or by manual workflow dispatch. macOS release builds fail closed unless signing and notarization are enabled. Windows signing is used when Azure Trusted Signing secrets are configured, and Linux AppImage builds remain unsigned. - ---- - -## Prerequisites - -### Required secrets - -All secrets are configured in **GitHub Actions repository secrets**. - -#### Apple code signing and notarization (macOS) - -| Secret | Description | -| ------------------ | ------------------------------------------------------------------------ | -| `CSC_LINK` | Base64-encoded `.p12` Developer ID Application certificate + private key | -| `CSC_KEY_PASSWORD` | Password for the `.p12` export | -| `APPLE_API_KEY` | Raw contents of the App Store Connect API `.p8` key file | -| `APPLE_API_KEY_ID` | App Store Connect API Key ID | -| `APPLE_API_ISSUER` | App Store Connect API Issuer ID | - -Setup: - -1. Create a `Developer ID Application` certificate in the Apple Developer portal. -2. Export the certificate + private key as `.p12` from Keychain Access. -3. Base64-encode the `.p12` file and store the result as `CSC_LINK`. -4. Store the `.p12` export password as `CSC_KEY_PASSWORD`. -5. In App Store Connect, create a Team API key. Store the `.p8` file contents as `APPLE_API_KEY`, the Key ID as `APPLE_API_KEY_ID`, and the Issuer ID as `APPLE_API_ISSUER`. -6. The workflow writes `APPLE_API_KEY` to a temporary `AuthKey_.p8` file at runtime. - -#### Azure Trusted Signing (Windows) - -| Secret | Description | -| ------------------------------------------------ | ---------------------------------------------- | -| `AZURE_TENANT_ID` | Entra (Azure AD) tenant ID | -| `AZURE_CLIENT_ID` | Service principal (app registration) client ID | -| `AZURE_CLIENT_SECRET` | Service principal client secret | -| `AZURE_TRUSTED_SIGNING_ENDPOINT` | Azure Trusted Signing service endpoint URL | -| `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME` | Trusted Signing account name | -| `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME` | Certificate profile name | -| `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME` | Publisher name for the signing certificate | - -Setup: - -1. Create an Azure Trusted Signing account and certificate profile in the Azure portal. -2. Create or choose an Entra app registration (service principal) and grant it Trusted Signing permissions. -3. Create a client secret for the service principal. -4. Add all seven secrets to GitHub Actions. - -#### npm publishing (CLI, manual) - -The release workflow does not publish to npm. To ship `okcodes` after a desktop release (or on its own), run locally from a clean checkout with versions aligned to the release: - -```bash -node scripts/update-release-package-versions.ts X.Y.Z -bun install --frozen-lockfile -bun run build --filter=@okcode/web --filter=okcodes -NODE_AUTH_TOKEN= node apps/server/scripts/cli.ts publish --tag latest --app-version X.Y.Z --verbose -``` - -| Secret / token | Description | -| ----------------- | ----------------------------------------------------------------------------------------------- | -| `NODE_AUTH_TOKEN` | npm access token with publish rights on `okcodes`, or use `npm login` in the same shell session | - -#### Post-release automation - -| Secret | Description | -| ------------------------- | ---------------------------------------------------- | -| `RELEASE_APP_ID` | GitHub App ID used for the post-release version bump | -| `RELEASE_APP_PRIVATE_KEY` | Private key (PEM) for the GitHub App | - -The GitHub App must be installed on the repository with write access to contents. It must be allowed to push to `main` (add as a bypass actor if branch protection is enabled). - -### Required tools and versions - -| Tool | Version | Source | -| ------- | ---------- | ----------------------------- | -| Bun | `^1.3.9` | `package.json` `engines.bun` | -| Node.js | `^24.13.1` | `package.json` `engines.node` | -| Turbo | `^2.3.3` | `devDependencies` | - -These are installed automatically in CI via `oven-sh/setup-bun` and `actions/setup-node` using the version files in `package.json`. +The next stable train ships one semver across all blocking surfaces: -### Permissions needed +- macOS arm64 desktop DMG plus updater metadata +- Windows x64 signed NSIS installer +- Linux x64 AppImage +- iOS TestFlight build from the same tag +- `okcodes` npm package from the same tag -- **GitHub:** Write access to the repository (for tagging and releases). -- **npm:** Only if you manually publish the `okcodes` package (not part of the release workflow). -- **Apple Developer:** Team membership with Developer ID Application certificate rights. -- **Azure:** Service principal with Azure Trusted Signing permissions. +`docs/release.md` is the source of truth for release policy, release gates, and the platform matrix. Treat `docs/releases/README.md` and README release references as pointers only. ---- +## Defaults -## Version numbering +- iOS is TestFlight-only for this release train. +- Intel mac is non-blocking and runs in the separate `Desktop Intel Compatibility` workflow. +- Android is non-blocking. +- Windows stable support requires signing. Do not ship unsigned Windows artifacts as stable. -OK Code follows [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html): +## Versioning and promotion -``` -MAJOR.MINOR.PATCH -``` - -- **MAJOR** -- breaking changes to the CLI interface, server API, or desktop app behavior. -- **MINOR** -- new features, backward-compatible additions. -- **PATCH** -- bug fixes, performance improvements, documentation corrections. - -### Prerelease conventions - -Prerelease versions use a hyphenated suffix after the patch number: - -``` -X.Y.Z-