Merge remote-tracking branch 'origin/main' into feat/voice-aurora-stage

This commit is contained in:
Claude 2026-10-02 19:04:06 +00:00
commit 3b5d0d2b95
No known key found for this signature in database
16 changed files with 368 additions and 60 deletions

View File

@ -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:"

View File

@ -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

View File

@ -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

View File

@ -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();
});

View File

@ -29,6 +29,26 @@
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
})();
</script>
<script>
// Reload once when the browser aborts part of the module graph while the app
// boots (a network interface changed mid-request). main.tsx clears the flag
// after a successful boot; a second failure in a row is left to the browser.
addEventListener(
"error",
(event) => {
const target = event.target;
if (!(target instanceof HTMLScriptElement) || target.type !== "module") return;
try {
if (sessionStorage.getItem("rakazo:boot-reload")) return;
sessionStorage.setItem("rakazo:boot-reload", "1");
} catch {
return;
}
location.reload();
},
true,
);
</script>
</head>
<body>
<div id="root"></div>

View File

@ -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 })),
);

View File

@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { loadWithRecovery } from "./lazy-recovery.js";
function fakeStorage(initial: Record<string, string> = {}) {
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<string>>()
.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<string>>().mockRejectedValue(fetchFailure());
const pending = loadWithRecovery("pages/Onboarding", load, {
storage,
reload,
retryDelayMs: 0,
});
const outcome = await Promise.race([
pending.then(() => "resolved"),
new Promise<string>((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<string>>().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");
});
});

View File

@ -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<Storage, "getItem" | "setItem" | "removeItem">;
reload?: () => void;
}
const STORAGE_PREFIX = "rakazo:module-reload:";
function delay(ms: number) {
return new Promise<void>((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<T>(
key: string,
load: () => Promise<T>,
options: RecoveryOptions = {},
): Promise<T> {
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<never>(() => undefined);
}
}
/** `React.lazy` for route and overlay modules that must survive a failed module fetch. */
export function lazyWithRecovery<T extends ComponentType<any>>(
key: string,
load: () => Promise<{ default: T }>,
) {
return lazy(() => loadWithRecovery(key, load));
}

View File

@ -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();

View File

@ -139,7 +139,6 @@ import {
import {
type ClipboardEvent,
type DragEvent,
lazy,
type MutableRefObject,
memo,
type RefObject,
@ -202,6 +201,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";
@ -283,27 +283,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"

View File

@ -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 })),
);

View File

@ -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-<full-commit>` | 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.

View File

@ -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;

View File

@ -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<string, string>): { 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);
}
});
});

View File

@ -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)"

View File

@ -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}"