diff --git a/.github/workflows/evo-deploy.yml b/.github/workflows/evo-deploy.yml index 697afc9..7aad6e8 100644 --- a/.github/workflows/evo-deploy.yml +++ b/.github/workflows/evo-deploy.yml @@ -1,9 +1,11 @@ name: evo-deploy -# Deploy evOo-local on the Evo host when main advances, or on manual dispatch. +# Deploy evOo-local on the Evo host once Product CI has passed on main, or on manual dispatch. # Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow). on: - push: + workflow_run: + workflows: [ci] + types: [completed] branches: [main] workflow_dispatch: inputs: @@ -29,7 +31,7 @@ concurrency: jobs: deploy: - if: vars.EVO_DEPLOY_ENABLED == 'true' + if: vars.EVO_DEPLOY_ENABLED == 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 30 steps: @@ -37,12 +39,12 @@ jobs: uses: actions/checkout@v5 with: persist-credentials: false - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }} - name: Deploy evOo-local on Evo env: EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }} - EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} + EVO_DEPLOY_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }} EVO_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }} EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }} @@ -72,7 +74,7 @@ jobs: { echo "### Evo deploy" echo - echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}\`" + echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.event.workflow_run.head_sha }}\`" echo "- Dir: \`$EVO_DEPLOY_DIR\`" if [[ -f "$EVO_STATE_FILE" ]]; then echo "- State:" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 00cd53b..25756e7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -111,6 +111,24 @@ jobs: env: BOX_API_KEY: ${{ secrets.BOX_API_KEY }} run: test -n "$BOX_API_KEY" + - name: Pick E2E ports for this runner + # Several runners share one host. Each runner gets its own port set so the + # port-freeing steps and the harness never touch another job's servers. No runner + # uses the harness defaults: jobs from branches that still free the fixed ports + # cannot kill ours, and we cannot kill theirs. + run: | + set -euo pipefail + suffix="${RUNNER_NAME##*-}" + offset=100 + if [[ "$suffix" =~ ^[0-9]+$ ]]; then + offset=$((suffix * 100)) + fi + { + echo "API_PORT=$((3110 + offset))" + echo "WEB_PORT=$((5180 + offset))" + echo "WWW_PORT=$((4321 + offset))" + } >> "$GITHUB_ENV" + echo "E2E ports for ${RUNNER_NAME}: api $((3110 + offset)), web $((5180 + offset)), www $((4321 + offset))" - name: Free stale E2E ports run: bash scripts/free-e2e-ports.sh - name: Run Playwright tests diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index 3c58ad7..d1904b5 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -71,10 +71,14 @@ jobs: with: persist-credentials: false - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - name: Resolve the lowercase image namespace + id: ns + # GHCR rejects repository names with capitals; the GitHub repository slug has them. + run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: - images: ghcr.io/${{ github.repository }}/${{ matrix.name }} + images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} tags: type=sha,prefix=sha- - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: @@ -93,8 +97,8 @@ jobs: name: Build ${{ matrix.name }} ${{ matrix.arch }} if: github.event_name != 'pull_request' # Each architecture builds natively on its own runner and pushes by digest only. - # amd64 on evo Linux runners; arm64 on Apple Silicon macOS runners (linux/arm64 via buildx). - # No QEMU: emulated arm64 took hours for the desktop-sized computer image. + # amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx, + # and QEMU emulation took hours for the desktop-sized computer image. # See docs/self-host.md (Published images and tags). runs-on: ${{ matrix.runner }} timeout-minutes: 90 @@ -105,7 +109,7 @@ jobs: fail-fast: false matrix: name: [app, updater, computer] - arch: [amd64, arm64] + arch: [amd64] include: # Only the updater image contains the Docker CLI; the application image stays unprivileged. # The supervisor is not a separate published image: it runs from `app` on the internal network. @@ -120,8 +124,6 @@ jobs: context: infra/sandboxes/computer - arch: amd64 runner: [self-hosted, evoo-ci, Linux] - - arch: arm64 - runner: [self-hosted, evoo-ci, macOS] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: @@ -133,11 +135,15 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve the lowercase image namespace + id: ns + # GHCR rejects repository names with capitals; the GitHub repository slug has them. + run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: # Labels only; the tagged manifest is assembled in the publish job. - images: ghcr.io/${{ github.repository }}/${{ matrix.name }} + images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} tags: type=sha,prefix=sha- - id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 @@ -147,7 +153,7 @@ jobs: platforms: linux/${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} # A fork can publish only to its own namespace; no credential can redirect this value. - outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true # GET /health can report the exact source commit without a deployment-supplied override. build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }} @@ -199,11 +205,15 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve the lowercase image namespace + id: ns + # GHCR rejects repository names with capitals; the GitHub repository slug has them. + run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: # A fork can publish only to its own namespace; no credential can redirect this value. - images: ghcr.io/${{ github.repository }}/${{ matrix.name }} + images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} tags: | type=semver,pattern=v{{version}} type=semver,pattern=v{{major}}.{{minor}} @@ -211,11 +221,11 @@ jobs: type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }} # Stable releases only: a hyphen marks a prerelease (v1.0.0-rc.1) that must not move latest. type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - - name: Assemble and verify the multi-arch manifest + - name: Assemble and verify the manifest id: manifest working-directory: /tmp/digests env: - IMAGE: ghcr.io/${{ github.repository }}/${{ matrix.name }} + IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail @@ -227,7 +237,7 @@ jobs: for digest in *; do sources+=("${IMAGE}@sha256:${digest}") done - if [[ "${#sources[@]}" -ne 2 ]]; then + if [[ "${#sources[@]}" -ne 1 ]]; then echo "Expected one digest per architecture, found ${#sources[@]}." >&2 exit 1 fi @@ -236,13 +246,13 @@ jobs: first_tag="$(head -n 1 <<< "$TAGS")" platforms="$(docker buildx imagetools inspect "$first_tag" \ --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')" - for want in linux/amd64 linux/arm64; do + for want in linux/amd64; do grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; } done echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT" - name: Attest the published image uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-name: ghcr.io/${{ github.repository }}/${{ matrix.name }} + subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true diff --git a/apps/web/e2e/module-recovery.spec.ts b/apps/web/e2e/module-recovery.spec.ts new file mode 100644 index 0000000..f557942 --- /dev/null +++ b/apps/web/e2e/module-recovery.spec.ts @@ -0,0 +1,38 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +/** Abort the first request for a module once, then let every later request through. */ +async function abortFirstRequest(page: Page, pattern: string) { + const counter = { aborted: 0 }; + await page.route(pattern, async (route) => { + if (counter.aborted === 0) { + counter.aborted += 1; + await route.abort("failed"); + return; + } + await route.continue(); + }); + return counter; +} + +test("the app reloads itself once when a module request aborts while booting", async ({ page }) => { + const counter = await abortFirstRequest(page, "**/src/App.tsx"); + + await page.goto("/sign-in", { waitUntil: "commit" }); + + await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible(); + expect(counter.aborted).toBe(1); + expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull(); +}); + +test("a lazy route recovers when its module request aborts", async ({ page }) => { + const counter = await abortFirstRequest(page, "**/src/pages/Auth.tsx"); + + await page.goto("/sign-in"); + + await expect(page.getByRole("heading", { name: "Sign in to EvOo" })).toBeVisible(); + expect(counter.aborted).toBe(1); + expect( + await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")), + ).toBeNull(); +}); diff --git a/apps/web/index.html b/apps/web/index.html index 80df422..c626fbd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -29,6 +29,26 @@ if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a"); })(); +
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 979d4d6..12dd355 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,10 +1,11 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts"; import { Button, Skeleton } from "@rakazo/ui-web"; -import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Navigate, Route, Routes, useSearchParams } from "react-router-dom"; import { LoadingState } from "./components/ai/primitives"; import { authClient } from "./lib/auth"; +import { lazyWithRecovery } from "./lib/lazy-recovery"; import { markAfterPaint, markOnce } from "./lib/performance"; import { holdUnreachableGate, @@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings"; import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback"; import { ShellPage } from "./pages/Shell"; -const AuthPage = lazy(() => +const AuthPage = lazyWithRecovery("pages/Auth", () => import("./pages/Auth").then((module) => ({ default: module.AuthPage })), ); -const PasswordResetPage = lazy(() => +const PasswordResetPage = lazyWithRecovery("pages/Auth", () => import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })), ); -const OnboardingPage = lazy(() => +const OnboardingPage = lazyWithRecovery("pages/Onboarding", () => import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })), ); -const WelcomePage = lazy(() => +const WelcomePage = lazyWithRecovery("pages/Welcome", () => import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })), ); diff --git a/apps/web/src/lib/lazy-recovery.test.ts b/apps/web/src/lib/lazy-recovery.test.ts new file mode 100644 index 0000000..d75d128 --- /dev/null +++ b/apps/web/src/lib/lazy-recovery.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadWithRecovery } from "./lazy-recovery.js"; + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + values, + }; +} + +const fetchFailure = () => new TypeError("Failed to fetch dynamically imported module"); +const flag = "rakazo:module-reload:pages/Onboarding"; + +describe("loadWithRecovery", () => { + it("returns the module and clears the reload flag when the import succeeds", async () => { + const storage = fakeStorage({ [flag]: "1" }); + const reload = vi.fn(); + const loaded = await loadWithRecovery("pages/Onboarding", async () => "module", { + storage, + reload, + }); + expect(loaded).toBe("module"); + expect(storage.values.has(flag)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("retries the import in place before touching the page", async () => { + const storage = fakeStorage(); + const reload = vi.fn(); + const load = vi + .fn<() => Promise>() + .mockRejectedValueOnce(fetchFailure()) + .mockResolvedValueOnce("module"); + const loaded = await loadWithRecovery("pages/Onboarding", load, { + storage, + reload, + retryDelayMs: 0, + }); + expect(loaded).toBe("module"); + expect(load).toHaveBeenCalledTimes(2); + expect(reload).not.toHaveBeenCalled(); + expect(storage.values.has(flag)).toBe(false); + }); + + it("reloads the page once when the retry fails as well", async () => { + const storage = fakeStorage(); + const reload = vi.fn(); + const load = vi.fn<() => Promise>().mockRejectedValue(fetchFailure()); + const pending = loadWithRecovery("pages/Onboarding", load, { + storage, + reload, + retryDelayMs: 0, + }); + const outcome = await Promise.race([ + pending.then(() => "resolved"), + new Promise((resolve) => setTimeout(() => resolve("still pending"), 20)), + ]); + expect(outcome).toBe("still pending"); + expect(load).toHaveBeenCalledTimes(2); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.values.get(flag)).toBe("1"); + }); + + it("surfaces the failure instead of reloading again after a reload did not help", async () => { + const storage = fakeStorage({ [flag]: "1" }); + const reload = vi.fn(); + const failure = fetchFailure(); + const load = vi.fn<() => Promise>().mockRejectedValue(failure); + await expect( + loadWithRecovery("pages/Onboarding", load, { storage, reload, retryDelayMs: 0 }), + ).rejects.toBe(failure); + expect(reload).not.toHaveBeenCalled(); + expect(storage.values.get(flag)).toBe("1"); + }); +}); diff --git a/apps/web/src/lib/lazy-recovery.ts b/apps/web/src/lib/lazy-recovery.ts new file mode 100644 index 0000000..6a5a651 --- /dev/null +++ b/apps/web/src/lib/lazy-recovery.ts @@ -0,0 +1,58 @@ +import type { ComponentType } from "react"; +import { lazy } from "react"; + +export interface RecoveryOptions { + /** Pause before the in-place retry; the default covers a brief interface change. */ + retryDelayMs?: number; + storage?: Pick; + reload?: () => void; +} + +const STORAGE_PREFIX = "rakazo:module-reload:"; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Load a lazily imported module, recovering from a fetch the browser aborted + * (a network interface changed, the deploy behind the URL moved on). The first + * recovery is a plain retry; when that fails too, the page reloads once, because + * browsers keep a failed module in their module map and keep rejecting the same + * import without touching the network. A second failure after the reload is + * surfaced to the caller. + */ +export async function loadWithRecovery( + key: string, + load: () => Promise, + options: RecoveryOptions = {}, +): Promise { + const storage = options.storage ?? window.sessionStorage; + const reload = options.reload ?? (() => window.location.reload()); + const storageKey = `${STORAGE_PREFIX}${key}`; + try { + const loaded = await load(); + storage.removeItem(storageKey); + return loaded; + } catch { + await delay(options.retryDelayMs ?? 300); + } + try { + const loaded = await load(); + storage.removeItem(storageKey); + return loaded; + } catch (error) { + if (storage.getItem(storageKey)) throw error; + storage.setItem(storageKey, "1"); + reload(); + return new Promise(() => undefined); + } +} + +/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */ +export function lazyWithRecovery>( + key: string, + load: () => Promise<{ default: T }>, +) { + return lazy(() => loadWithRecovery(key, load)); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a0bbd2c..1b03bf7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale"; import "./styles.css"; markOnce("rk:renderer:module-evaluated"); +try { + // Set by the inline boot script in index.html before it reloads once. + sessionStorage.removeItem("rakazo:boot-reload"); +} catch {} installPreloadRecovery(); applyUiDirection(resolveUiLocale()); applyUiAppearance(); diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index ee2d117..6d0ba02 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -139,7 +139,6 @@ import { import { type ClipboardEvent, type DragEvent, - lazy, type MutableRefObject, memo, type RefObject, @@ -201,6 +200,7 @@ import { resolveWorkingGroupBots, } from "../lib/group-stage"; import { buildHeuteBrief } from "../lib/heute-brief"; +import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; @@ -282,27 +282,29 @@ import { } from "./shell/message-cards"; import { WindowChrome } from "./WindowChrome"; -const BotContextMenu = lazy(() => +const BotContextMenu = lazyWithRecovery("BotContextMenu", () => import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })), ); -const MessagingSettingsOverlay = lazy(() => +const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () => import("./MessagingSettingsOverlay").then((module) => ({ default: module.MessagingSettingsOverlay, })), ); -const SettingsOverlay = lazy(() => +const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () => import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })), ); -const PeerMessagesOverlay = lazy(() => +const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () => import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })), ); -const PluginsOverlay = lazy(() => +const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () => import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })), ); -const McpServersOverlay = lazy(() => +const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () => import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })), ); -const CallView = lazy(() => import("./CallView").then((module) => ({ default: module.CallView }))); +const CallView = lazyWithRecovery("CallView", () => + import("./CallView").then((module) => ({ default: module.CallView })), +); type Panel = | "computer" diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index fcbede4..74c336c 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -25,15 +25,16 @@ import { Toggle, } from "@rakazo/ui-web"; import { X } from "lucide-react"; -import { lazy, Suspense, useEffect, useId, useRef, useState } from "react"; +import { Suspense, useEffect, useId, useRef, useState } from "react"; +import { lazyWithRecovery } from "../../lib/lazy-recovery"; import { rpc } from "../../lib/rpc"; import { AvatarStudioPopover } from "./avatar-studio-popover"; -const ScratchpadSection = lazy(() => +const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () => import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })), ); -const KnowledgeSection = lazy(() => +const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () => import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })), ); diff --git a/docs/self-host.md b/docs/self-host.md index ce91160..8510b82 100644 --- a/docs/self-host.md +++ b/docs/self-host.md @@ -36,8 +36,9 @@ Compose stack requires `SANDBOX_SUPERVISOR_TOKEN` for every provider; leave it e Optional: set `OPENROUTER_API_KEY` or connect a model in the UI after signup. -The example defaults to `edge` (main builds). Every publish is multi-arch (`amd64` + `arm64`), so -arm64 hosts need no special tag. Do not assume `latest` is present until a stable release exists. +The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now; +arm64 publishing is paused until an arm64 runner with Docker buildx exists. Do not assume `latest` +is present until a stable release exists. Open [http://127.0.0.1:5173](http://127.0.0.1:5173). The first registered user becomes the deployment owner. Put TLS in front of `:5173` for a public host and set the three public origins to @@ -483,8 +484,9 @@ your CI cannot publish into someone else's. | `sha-` | every push and manual run | source-addressed; used by the updater sidecar | | `edge` | pushes to main | yes, to the newest main build | -Every publish, including `edge` from main merges, is multi-arch (`amd64` + `arm64`): each -architecture builds natively on its own runner and one manifest is assembled per image. Until a +Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds +natively on the Linux runner and one manifest is assembled per image. arm64 publishing is paused +until an arm64 runner with Docker buildx exists. Until a stable `vX.Y.Z` has been published, GHCR may only have `edge` and `sha-*` tags; do not pin `latest` unless that tag exists in the registry. diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index b4654c4..67e992a 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -19,31 +19,38 @@ const workflow = parse(workflowText) as { }; describe("server image publish workflow", () => { - it("builds every architecture natively instead of emulating arm64", () => { + it("builds amd64 natively on the Linux runner and keeps arm64 paused, not emulated", () => { expect(workflowText).not.toContain("setup-qemu-action"); + expect(workflowText).toContain("arm64 is paused"); const build = workflow.jobs.build; expect(build["runs-on"]).toContain("matrix.runner"); - expect(build.strategy?.matrix?.arch).toEqual(["amd64", "arm64"]); + expect(build.strategy?.matrix?.arch).toEqual(["amd64"]); const runners = Object.fromEntries( (build.strategy?.matrix?.include ?? []) .filter((entry) => entry.arch !== undefined) .map((entry) => [entry.arch, entry.runner]), ); - expect(runners).toEqual({ - amd64: ["self-hosted", "evoo-ci", "Linux"], - arm64: ["self-hosted", "evoo-ci", "macOS"], - }); + expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] }); }); - it("publishes one verified multi-arch manifest per image after both builds", () => { + it("publishes one verified manifest per image after the build", () => { const publish = workflow.jobs.publish; expect(publish.needs).toBe("build"); expect(workflowText).toContain("push-by-digest=true"); expect(workflowText).toContain("docker buildx imagetools create"); - expect(workflowText).toContain("for want in linux/amd64 linux/arm64"); + expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]'); + expect(workflowText).toContain("for want in linux/amd64; do"); expect(workflowText).toContain("actions/attest-build-provenance@"); }); + it("names every image under the lowercase repository namespace", () => { + expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}"); + const references = + workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? []; + expect(references.length).toBeGreaterThanOrEqual(6); + expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}"); + }); + it("keeps pull requests read-only and every action pinned to a commit", () => { const validate = workflow.jobs.validate; const build = workflow.jobs.build; diff --git a/packages/testkit/src/e2e-ports.test.ts b/packages/testkit/src/e2e-ports.test.ts index eac409a..247407c 100644 --- a/packages/testkit/src/e2e-ports.test.ts +++ b/packages/testkit/src/e2e-ports.test.ts @@ -1,9 +1,12 @@ -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh"); +const script = readFileSync(scriptPath, "utf8"); const playwrightWorkflow = readFileSync( path.join(repoRoot, ".github/workflows/playwright.yml"), "utf8", @@ -15,20 +18,62 @@ function portsFromShellArray(source: string, name: string): number[] { return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port)); } +/** Runs the script on a fake Linux host whose fuser never finds a listener, so nothing is killed. */ +function runScript(env: Record): { status: number; output: string } { + const fakeBin = mkdtempSync(path.join(tmpdir(), "evoo-e2e-ports-")); + for (const [name, body] of [ + ["uname", "echo Linux"], + ["fuser", "exit 1"], + ] as const) { + const file = path.join(fakeBin, name); + writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`); + chmodSync(file, 0o755); + } + try { + const output = execFileSync("bash", [scriptPath], { + env: { PATH: `${fakeBin}:${process.env.PATH}`, ...env }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, output }; + } catch (error) { + const failure = error as { status: number; stdout: string; stderr: string }; + return { status: failure.status, output: `${failure.stdout}${failure.stderr}` }; + } +} + +function announcedPorts(output: string): number[] { + const match = output.match(/E2E ports \(([^)]*)\)/); + expect(match, "announced E2E ports").toBeTruthy(); + return match![1].split(" ").map(Number); +} + describe("free-e2e-ports.sh", () => { - const script = readFileSync(scriptPath, "utf8"); - - it("declares dedicated E2E ports separate from live dev ports", () => { + it("keeps the live dev ports apart from the default E2E ports", () => { const livePorts = portsFromShellArray(script, "LIVE_PORTS"); - const e2ePorts = portsFromShellArray(script, "E2E_PORTS"); - expect(livePorts).toEqual([3100, 5173]); + + const run = runScript({}); + expect(run.status).toBe(0); + const e2ePorts = announcedPorts(run.output); expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]); for (const livePort of livePorts) { expect(e2ePorts).not.toContain(livePort); } }); + it("follows the harness port settings so runners on one host never touch each other", () => { + const run = runScript({ API_PORT: "3310", WEB_PORT: "5380", WWW_PORT: "4521" }); + expect(run.status).toBe(0); + expect(announcedPorts(run.output)).toEqual([3310, 3311, 5380, 4521]); + }); + + it("refuses a port setting that is not a number", () => { + const run = runScript({ API_PORT: "abc" }); + expect(run.status).toBe(1); + expect(run.output).toContain("API_PORT must be a port number"); + }); + it("uses fuser and skips non-Linux hosts", () => { expect(script).toContain('fuser -k "$spec"'); expect(script).toContain('uname -s)" != "Linux"'); @@ -44,4 +89,13 @@ describe("free-e2e-ports.sh", () => { playwrightWorkflow.indexOf("Run marketing homepage E2E"), ); }); + + it("gets its ports from the workflow step that picks them per runner", () => { + const pickStep = playwrightWorkflow.indexOf("Pick E2E ports for this runner"); + expect(pickStep).toBeGreaterThan(-1); + expect(pickStep).toBeLessThan(playwrightWorkflow.indexOf("free-e2e-ports.sh")); + for (const setting of ["API_PORT=", "WEB_PORT=", "WWW_PORT="]) { + expect(playwrightWorkflow).toContain(setting); + } + }); }); diff --git a/scripts/free-e2e-ports.sh b/scripts/free-e2e-ports.sh index 754bcf8..32c29d8 100755 --- a/scripts/free-e2e-ports.sh +++ b/scripts/free-e2e-ports.sh @@ -1,11 +1,22 @@ #!/usr/bin/env bash # Free stale listeners on dedicated Playwright/E2E ports before harness bind. # Never touches live dev stack ports 3100 (API) or 5173 (web). +# The ports follow API_PORT, WEB_PORT and WWW_PORT so that runners sharing one host, +# each with its own port set, never kill another job's servers. set -euo pipefail +for setting in API_PORT WEB_PORT WWW_PORT; do + value="${!setting:-}" + if [[ -n "$value" && ! "$value" =~ ^[0-9]+$ ]]; then + echo "free-e2e-ports: $setting must be a port number, got: $value" >&2 + exit 1 + fi +done + readonly LIVE_PORTS=(3100 5173) -readonly E2E_PORTS=(3110 3111 5180 4321) +api_port="${API_PORT:-3110}" +readonly E2E_PORTS=("$api_port" "$((api_port + 1))" "${WEB_PORT:-5180}" "${WWW_PORT:-4321}") if [[ "$(uname -s)" != "Linux" ]]; then echo "free-e2e-ports: skip (not Linux)" diff --git a/scripts/publish-playwright-report.sh b/scripts/publish-playwright-report.sh index ffdbdcf..afbc04a 100755 --- a/scripts/publish-playwright-report.sh +++ b/scripts/publish-playwright-report.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}" -: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}" -: "${S3_BUCKET:?S3_BUCKET is required}" -: "${S3_ENDPOINT:?S3_ENDPOINT is required}" -: "${PLAYWRIGHT_PUBLIC_BASE_URL:?PLAYWRIGHT_PUBLIC_BASE_URL is required}" +# Publishing is optional: without the S3 settings the gallery is skipped and the job stays green. +for setting in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY S3_BUCKET S3_ENDPOINT PLAYWRIGHT_PUBLIC_BASE_URL; do + if [[ -z "${!setting:-}" ]]; then + echo "::notice::Skipping the Playwright report publish: $setting is not configured." + exit 0 + fi +done : "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}" : "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}" : "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"