ci: publish amd64 images under a lowercase name and deploy only after green CI (#97)
* ci: publish amd64 images under a lowercase name and deploy only after green CI Image publishing never succeeded: GHCR rejects the capitalized repository slug, and the arm64 jobs failed on the macOS runners, which have no Docker buildx. Resolve the namespace in lowercase and build amd64 only until an arm64 runner with buildx exists; the manifest check expects one digest. The Evo deploy ran on every push to main regardless of the result of Product CI, so red commits went live. Trigger it from the completed ci workflow instead and deploy the head it verified; manual dispatch stays. The Playwright report publish failed every nightly run because the S3 settings are not configured. The script now skips the gallery with a notice when they are missing instead of failing the job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * ci: give each runner on a shared host its own E2E ports Two Linux runners live on the same host. Every Web E2E job first frees "stale" listeners on the fixed ports 3110, 3111, 5180 and 4321 with fuser -k, so a job starting on the second runner killed the Vite server of the job already running on the first one. The victim died with SIGKILL (exit 137), and its remaining tests failed on reloads that could no longer reach a server. This is what made the Web E2E run on this PR red, and it explains the recurring reload failures on main whenever two runs overlapped. The workflow now derives API_PORT, WEB_PORT and WWW_PORT from the runner name (suffix -N means an offset of N times 100), and the port-freeing script follows those settings instead of a hard-coded list. The harness, Vite, the API and the marketing site already read these variables. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(testkit): cover the env-driven E2E ports instead of a fixed list The ports test parsed a hard-coded array that no longer exists. It now runs the script on a fake host whose fuser finds nothing, and checks the default ports, the per-runner override, the rejection of a non-numeric setting, and that the workflow picks the ports before freeing them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(updater): expect the amd64-only publish and the lowercase namespace The workflow test still described the multi-arch publish. It now checks that only amd64 builds natively, that arm64 is documented as paused, that one digest feeds the manifest, and that every image name comes from the lowercase namespace step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * ci: keep every runner off the default E2E ports Branches that still free the fixed ports 3110, 3111, 5180 and 4321 keep running on the shared host (main, the nightly run, other open PRs) until they carry the per-runner ports. A job of ours on the runner without a numeric suffix used exactly those defaults, so such a neighbour could still kill it, and its own port freeing could kill the neighbour. The unsuffixed runner now starts at offset 100, so no job from this workflow touches the defaults in either direction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): recover lazy routes and overlays from a failed module fetch When the browser aborts a module request while a page loads, the lazy import rejects and the route throws, leaving a blank page. Chromium does this whenever a network interface appears or disappears mid-request (net::ERR_NETWORK_CHANGED), which happens on a laptop that changes networks and on the CI host every time a container starts or stops. A Web E2E run hit exactly this: two modules of the onboarding page failed to load and the suite waited on an empty document. Lazy routes and overlays now load through a helper that retries the import once and, when the browser keeps rejecting it from its module map, reloads the page a single time before surfacing the error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit d5dacb12f14380071ef14ba8845d18c9d39e3fac) * fix(web): reload once when the module graph is aborted while booting The lazy-route recovery cannot help when the browser aborts a module of the static import graph: nothing of the app runs, the root stays empty, and the only trace is a console error. A Web E2E run hit this right after a page reload, when four modules of the shell failed with net::ERR_NETWORK_CHANGED and the shell never rendered. An inline script in the HTML entry now listens for the error event of the module script and reloads the page once, guarded by a session flag that the entry module clears after a successful boot. The existing preload recovery stays in place; it only covers Vite's chunk preloads of production builds. A new E2E spec aborts one module request during boot and one lazy route request, and expects the sign-in page to render in both cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit e5b7e036e1a60e7e270592c4b9382ee23ed45ccb) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
4995b66b93
commit
9c9188e657
14
.github/workflows/evo-deploy.yml
vendored
14
.github/workflows/evo-deploy.yml
vendored
@ -1,9 +1,11 @@
|
|||||||
name: evo-deploy
|
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).
|
# Runs ON the self-hosted Evo runner (no SSH, no deploy secrets in the workflow).
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [ci]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@ -29,7 +31,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
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]
|
runs-on: [self-hosted, evoo-ci, Linux]
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@ -37,12 +39,12 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
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
|
- name: Deploy evOo-local on Evo
|
||||||
env:
|
env:
|
||||||
EVO_DEPLOY_DIR: ${{ vars.EVO_DEPLOY_DIR }}
|
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_DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||||
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
|
EVO_MIGRATE: ${{ github.event.inputs.skip_migrate == true && 'false' || 'true' }}
|
||||||
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
|
EVO_RESTART_MODE: ${{ vars.EVO_RESTART_MODE || 'auto' }}
|
||||||
@ -72,7 +74,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "### Evo deploy"
|
echo "### Evo deploy"
|
||||||
echo
|
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\`"
|
echo "- Dir: \`$EVO_DEPLOY_DIR\`"
|
||||||
if [[ -f "$EVO_STATE_FILE" ]]; then
|
if [[ -f "$EVO_STATE_FILE" ]]; then
|
||||||
echo "- State:"
|
echo "- State:"
|
||||||
|
|||||||
18
.github/workflows/playwright.yml
vendored
18
.github/workflows/playwright.yml
vendored
@ -111,6 +111,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
||||||
run: test -n "$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
|
- name: Free stale E2E ports
|
||||||
run: bash scripts/free-e2e-ports.sh
|
run: bash scripts/free-e2e-ports.sh
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
|
|||||||
38
.github/workflows/publish-server-image.yml
vendored
38
.github/workflows/publish-server-image.yml
vendored
@ -71,10 +71,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
- 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
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
tags: type=sha,prefix=sha-
|
tags: type=sha,prefix=sha-
|
||||||
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
@ -93,8 +97,8 @@ jobs:
|
|||||||
name: Build ${{ matrix.name }} ${{ matrix.arch }}
|
name: Build ${{ matrix.name }} ${{ matrix.arch }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
# Each architecture builds natively on its own runner and pushes by digest only.
|
# 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).
|
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
|
||||||
# No QEMU: emulated arm64 took hours for the desktop-sized computer image.
|
# and QEMU emulation took hours for the desktop-sized computer image.
|
||||||
# See docs/self-host.md (Published images and tags).
|
# See docs/self-host.md (Published images and tags).
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
@ -105,7 +109,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
name: [app, updater, computer]
|
name: [app, updater, computer]
|
||||||
arch: [amd64, arm64]
|
arch: [amd64]
|
||||||
include:
|
include:
|
||||||
# Only the updater image contains the Docker CLI; the application image stays unprivileged.
|
# 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.
|
# 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
|
context: infra/sandboxes/computer
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
runner: [self-hosted, evoo-ci, Linux]
|
runner: [self-hosted, evoo-ci, Linux]
|
||||||
- arch: arm64
|
|
||||||
runner: [self-hosted, evoo-ci, macOS]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||||
with:
|
with:
|
||||||
@ -133,11 +135,15 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# Labels only; the tagged manifest is assembled in the publish job.
|
# 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-
|
tags: type=sha,prefix=sha-
|
||||||
- id: build
|
- id: build
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
@ -147,7 +153,7 @@ jobs:
|
|||||||
platforms: linux/${{ matrix.arch }}
|
platforms: linux/${{ matrix.arch }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
# A fork can publish only to its own namespace; no credential can redirect this value.
|
# 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.
|
# GET /health can report the exact source commit without a deployment-supplied override.
|
||||||
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
|
||||||
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
|
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
|
||||||
@ -199,11 +205,15 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# A fork can publish only to its own namespace; no credential can redirect this value.
|
# 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: |
|
tags: |
|
||||||
type=semver,pattern=v{{version}}
|
type=semver,pattern=v{{version}}
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
@ -211,11 +221,11 @@ jobs:
|
|||||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
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.
|
# 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, '-') }}
|
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
|
id: manifest
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
env:
|
env:
|
||||||
IMAGE: ghcr.io/${{ github.repository }}/${{ matrix.name }}
|
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -227,7 +237,7 @@ jobs:
|
|||||||
for digest in *; do
|
for digest in *; do
|
||||||
sources+=("${IMAGE}@sha256:${digest}")
|
sources+=("${IMAGE}@sha256:${digest}")
|
||||||
done
|
done
|
||||||
if [[ "${#sources[@]}" -ne 2 ]]; then
|
if [[ "${#sources[@]}" -ne 1 ]]; then
|
||||||
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
|
echo "Expected one digest per architecture, found ${#sources[@]}." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -236,13 +246,13 @@ jobs:
|
|||||||
first_tag="$(head -n 1 <<< "$TAGS")"
|
first_tag="$(head -n 1 <<< "$TAGS")"
|
||||||
platforms="$(docker buildx imagetools inspect "$first_tag" \
|
platforms="$(docker buildx imagetools inspect "$first_tag" \
|
||||||
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}')"
|
--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; }
|
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
|
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
|
||||||
- name: Attest the published image
|
- name: Attest the published image
|
||||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||||
with:
|
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 }}
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|||||||
38
apps/web/e2e/module-recovery.spec.ts
Normal file
38
apps/web/e2e/module-recovery.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -29,6 +29,26 @@
|
|||||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
if (meta) meta.setAttribute("content", theme === "light" ? "#fafaf8" : "#07080a");
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
|
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
|
||||||
import { Button, Skeleton } from "@rakazo/ui-web";
|
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 { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
|
||||||
import { LoadingState } from "./components/ai/primitives";
|
import { LoadingState } from "./components/ai/primitives";
|
||||||
import { authClient } from "./lib/auth";
|
import { authClient } from "./lib/auth";
|
||||||
|
import { lazyWithRecovery } from "./lib/lazy-recovery";
|
||||||
import { markAfterPaint, markOnce } from "./lib/performance";
|
import { markAfterPaint, markOnce } from "./lib/performance";
|
||||||
import {
|
import {
|
||||||
holdUnreachableGate,
|
holdUnreachableGate,
|
||||||
@ -17,16 +18,16 @@ import { LocalSettingsPage } from "./pages/LocalSettings";
|
|||||||
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
||||||
import { ShellPage } from "./pages/Shell";
|
import { ShellPage } from "./pages/Shell";
|
||||||
|
|
||||||
const AuthPage = lazy(() =>
|
const AuthPage = lazyWithRecovery("pages/Auth", () =>
|
||||||
import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
|
import("./pages/Auth").then((module) => ({ default: module.AuthPage })),
|
||||||
);
|
);
|
||||||
const PasswordResetPage = lazy(() =>
|
const PasswordResetPage = lazyWithRecovery("pages/Auth", () =>
|
||||||
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
|
import("./pages/Auth").then((module) => ({ default: module.PasswordResetPage })),
|
||||||
);
|
);
|
||||||
const OnboardingPage = lazy(() =>
|
const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
|
||||||
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
|
import("./pages/Onboarding").then((module) => ({ default: module.OnboardingPage })),
|
||||||
);
|
);
|
||||||
const WelcomePage = lazy(() =>
|
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
|
||||||
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
78
apps/web/src/lib/lazy-recovery.test.ts
Normal file
78
apps/web/src/lib/lazy-recovery.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/web/src/lib/lazy-recovery.ts
Normal file
58
apps/web/src/lib/lazy-recovery.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ import { resolveUiLocale } from "./lib/ui-locale";
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
markOnce("rk:renderer:module-evaluated");
|
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();
|
installPreloadRecovery();
|
||||||
applyUiDirection(resolveUiLocale());
|
applyUiDirection(resolveUiLocale());
|
||||||
applyUiAppearance();
|
applyUiAppearance();
|
||||||
|
|||||||
@ -139,7 +139,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
lazy,
|
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
memo,
|
memo,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
@ -201,6 +200,7 @@ import {
|
|||||||
resolveWorkingGroupBots,
|
resolveWorkingGroupBots,
|
||||||
} from "../lib/group-stage";
|
} from "../lib/group-stage";
|
||||||
import { buildHeuteBrief } from "../lib/heute-brief";
|
import { buildHeuteBrief } from "../lib/heute-brief";
|
||||||
|
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
||||||
import { localTimezone } from "../lib/local-timezone";
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import { copyableMessageText } from "../lib/message-text";
|
import { copyableMessageText } from "../lib/message-text";
|
||||||
import { messageProviderLabel } from "../lib/messaging";
|
import { messageProviderLabel } from "../lib/messaging";
|
||||||
@ -282,27 +282,29 @@ import {
|
|||||||
} from "./shell/message-cards";
|
} from "./shell/message-cards";
|
||||||
import { WindowChrome } from "./WindowChrome";
|
import { WindowChrome } from "./WindowChrome";
|
||||||
|
|
||||||
const BotContextMenu = lazy(() =>
|
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
||||||
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
|
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
|
||||||
);
|
);
|
||||||
const MessagingSettingsOverlay = lazy(() =>
|
const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
|
||||||
import("./MessagingSettingsOverlay").then((module) => ({
|
import("./MessagingSettingsOverlay").then((module) => ({
|
||||||
default: module.MessagingSettingsOverlay,
|
default: module.MessagingSettingsOverlay,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const SettingsOverlay = lazy(() =>
|
const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
|
||||||
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
|
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
|
||||||
);
|
);
|
||||||
const PeerMessagesOverlay = lazy(() =>
|
const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
|
||||||
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
|
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
|
||||||
);
|
);
|
||||||
const PluginsOverlay = lazy(() =>
|
const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
|
||||||
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
|
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
|
||||||
);
|
);
|
||||||
const McpServersOverlay = lazy(() =>
|
const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
|
||||||
import("./McpServersOverlay").then((module) => ({ default: module.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 =
|
type Panel =
|
||||||
| "computer"
|
| "computer"
|
||||||
|
|||||||
@ -25,15 +25,16 @@ import {
|
|||||||
Toggle,
|
Toggle,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { X } from "lucide-react";
|
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 { rpc } from "../../lib/rpc";
|
||||||
import { AvatarStudioPopover } from "./avatar-studio-popover";
|
import { AvatarStudioPopover } from "./avatar-studio-popover";
|
||||||
|
|
||||||
const ScratchpadSection = lazy(() =>
|
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
|
||||||
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
|
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const KnowledgeSection = lazy(() =>
|
const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
|
||||||
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
|
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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
|
The example defaults to `edge` (main builds). Published images are `linux/amd64` only for now;
|
||||||
arm64 hosts need no special tag. Do not assume `latest` is present until a stable release exists.
|
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
|
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
|
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 |
|
| `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 |
|
| `edge` | pushes to main | yes, to the newest main build |
|
||||||
|
|
||||||
Every publish, including `edge` from main merges, is multi-arch (`amd64` + `arm64`): each
|
Every publish, including `edge` from main merges, is `linux/amd64` only for now: the image builds
|
||||||
architecture builds natively on its own runner and one manifest is assembled per image. Until a
|
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
|
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.
|
`latest` unless that tag exists in the registry.
|
||||||
|
|
||||||
|
|||||||
@ -19,31 +19,38 @@ const workflow = parse(workflowText) as {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("server image publish workflow", () => {
|
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).not.toContain("setup-qemu-action");
|
||||||
|
expect(workflowText).toContain("arm64 is paused");
|
||||||
const build = workflow.jobs.build;
|
const build = workflow.jobs.build;
|
||||||
expect(build["runs-on"]).toContain("matrix.runner");
|
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(
|
const runners = Object.fromEntries(
|
||||||
(build.strategy?.matrix?.include ?? [])
|
(build.strategy?.matrix?.include ?? [])
|
||||||
.filter((entry) => entry.arch !== undefined)
|
.filter((entry) => entry.arch !== undefined)
|
||||||
.map((entry) => [entry.arch, entry.runner]),
|
.map((entry) => [entry.arch, entry.runner]),
|
||||||
);
|
);
|
||||||
expect(runners).toEqual({
|
expect(runners).toEqual({ amd64: ["self-hosted", "evoo-ci", "Linux"] });
|
||||||
amd64: ["self-hosted", "evoo-ci", "Linux"],
|
|
||||||
arm64: ["self-hosted", "evoo-ci", "macOS"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
const publish = workflow.jobs.publish;
|
||||||
expect(publish.needs).toBe("build");
|
expect(publish.needs).toBe("build");
|
||||||
expect(workflowText).toContain("push-by-digest=true");
|
expect(workflowText).toContain("push-by-digest=true");
|
||||||
expect(workflowText).toContain("docker buildx imagetools create");
|
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@");
|
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", () => {
|
it("keeps pull requests read-only and every action pinned to a commit", () => {
|
||||||
const validate = workflow.jobs.validate;
|
const validate = workflow.jobs.validate;
|
||||||
const build = workflow.jobs.build;
|
const build = workflow.jobs.build;
|
||||||
|
|||||||
@ -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 path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||||
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
|
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
|
||||||
|
const script = readFileSync(scriptPath, "utf8");
|
||||||
const playwrightWorkflow = readFileSync(
|
const playwrightWorkflow = readFileSync(
|
||||||
path.join(repoRoot, ".github/workflows/playwright.yml"),
|
path.join(repoRoot, ".github/workflows/playwright.yml"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@ -15,20 +18,62 @@ function portsFromShellArray(source: string, name: string): number[] {
|
|||||||
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
|
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", () => {
|
describe("free-e2e-ports.sh", () => {
|
||||||
const script = readFileSync(scriptPath, "utf8");
|
it("keeps the live dev ports apart from the default E2E ports", () => {
|
||||||
|
|
||||||
it("declares dedicated E2E ports separate from live dev ports", () => {
|
|
||||||
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
|
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
|
||||||
const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
|
|
||||||
|
|
||||||
expect(livePorts).toEqual([3100, 5173]);
|
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]);
|
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
|
||||||
for (const livePort of livePorts) {
|
for (const livePort of livePorts) {
|
||||||
expect(e2ePorts).not.toContain(livePort);
|
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", () => {
|
it("uses fuser and skips non-Linux hosts", () => {
|
||||||
expect(script).toContain('fuser -k "$spec"');
|
expect(script).toContain('fuser -k "$spec"');
|
||||||
expect(script).toContain('uname -s)" != "Linux"');
|
expect(script).toContain('uname -s)" != "Linux"');
|
||||||
@ -44,4 +89,13 @@ describe("free-e2e-ports.sh", () => {
|
|||||||
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
|
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
|
||||||
# Never touches live dev stack ports 3100 (API) or 5173 (web).
|
# 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
|
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 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
|
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||||
echo "free-e2e-ports: skip (not Linux)"
|
echo "free-e2e-ports: skip (not Linux)"
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
|
# Publishing is optional: without the S3 settings the gallery is skipped and the job stays green.
|
||||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}"
|
for setting in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY S3_BUCKET S3_ENDPOINT PLAYWRIGHT_PUBLIC_BASE_URL; do
|
||||||
: "${S3_BUCKET:?S3_BUCKET is required}"
|
if [[ -z "${!setting:-}" ]]; then
|
||||||
: "${S3_ENDPOINT:?S3_ENDPOINT is required}"
|
echo "::notice::Skipping the Playwright report publish: $setting is not configured."
|
||||||
: "${PLAYWRIGHT_PUBLIC_BASE_URL:?PLAYWRIGHT_PUBLIC_BASE_URL is required}"
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
|
: "${PLAYWRIGHT_RESULT:?PLAYWRIGHT_RESULT is required}"
|
||||||
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
|
: "${PLAYWRIGHT_RUN_ATTEMPT:?PLAYWRIGHT_RUN_ATTEMPT is required}"
|
||||||
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"
|
: "${PLAYWRIGHT_RUN_ID:?PLAYWRIGHT_RUN_ID is required}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user