From 1028752cbc874afe4087fd59250d6b1be5d1f119 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 20:11:13 +0200 Subject: [PATCH 1/4] fix: restore Workshop runtime URLs --- apps/docs/docs/management/workshop/index.mdx | 5 +++-- apps/docs/docusaurus.config.ts | 3 +++ .../src/components/workshop/DetailPage.tsx | 3 ++- apps/docs/src/lib/runtime-config.spec.ts | 19 +++++++++++++++++++ apps/docs/src/lib/runtime-config.ts | 16 ++++++++++++++++ apps/docs/src/pages/workshop/admin/index.tsx | 4 +++- apps/docs/src/pages/workshop/index.tsx | 3 ++- apps/docs/static/workshop-runtime-config.js | 1 + apps/workshop/Dockerfile | 8 ++++++++ apps/workshop/README.md | 3 ++- apps/workshop/docker-compose.yml | 3 +++ apps/workshop/entrypoint.sh | 18 ++++++++++++++++++ apps/workshop/tests/workshop-contracts.mjs | 11 +++++++++++ 13 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 apps/docs/src/lib/runtime-config.spec.ts create mode 100644 apps/docs/src/lib/runtime-config.ts create mode 100644 apps/docs/static/workshop-runtime-config.js diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 7caffcd42..472111f22 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -108,8 +108,9 @@ The public URL contract is: | `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production | | `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community | -The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart -services after changing these values. +The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The +combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts; +the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it. Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 13040b826..895dab602 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -12,6 +12,7 @@ if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) { const publicUrls = resolveHomarrUrlConfig({ homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL, workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl, + workshopWebUrl: process.env.WORKSHOP_WEB_URL, }); const config: Config = { title: "Homarr documentation", @@ -24,7 +25,9 @@ const config: Config = { projectName: "homarr", customFields: { workshopUrl: publicUrls.workshopApiUrl, + workshopWebUrl: publicUrls.workshopWebUrl, }, + scripts: [{ src: "/workshop-runtime-config.js" }], i18n: { defaultLocale: "en", locales: ["en"], diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 9c04274b1..79fb42408 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -29,6 +29,7 @@ import { toast } from "sonner"; import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; import { getWorkshopBackend } from "@site/src/lib/pocketbase"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; import type { SubmissionType } from "@site/src/lib/workshop-schema"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import { @@ -1028,7 +1029,7 @@ export default function MarketplaceDetailPage() { }> {() => ( - + )} diff --git a/apps/docs/src/lib/runtime-config.spec.ts b/apps/docs/src/lib/runtime-config.spec.ts new file mode 100644 index 000000000..90d3a9ed6 --- /dev/null +++ b/apps/docs/src/lib/runtime-config.spec.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { getRuntimeWorkshopApiUrl } from "./runtime-config"; + +describe("Workshop runtime configuration", () => { + afterEach(() => { + delete window.homarrRuntimeConfig; + }); + + test("prefers the URL supplied when the container starts", () => { + window.homarrRuntimeConfig = { workshopApiUrl: "https://v2.preview.homarr.dev" }; + + expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://v2.preview.homarr.dev"); + }); + + test("keeps the build-time URL as a fallback", () => { + expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev"); + }); +}); diff --git a/apps/docs/src/lib/runtime-config.ts b/apps/docs/src/lib/runtime-config.ts new file mode 100644 index 000000000..cf8cb5e3e --- /dev/null +++ b/apps/docs/src/lib/runtime-config.ts @@ -0,0 +1,16 @@ +interface HomarrRuntimeConfig { + homarrWebsiteUrl?: string; + workshopApiUrl?: string; + workshopWebUrl?: string; +} + +declare global { + interface Window { + homarrRuntimeConfig?: Readonly; + } +} + +export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => { + if (typeof window === "undefined") return configuredUrl; + return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin; +}; diff --git a/apps/docs/src/pages/workshop/admin/index.tsx b/apps/docs/src/pages/workshop/admin/index.tsx index a8baf5bcf..10244048a 100644 --- a/apps/docs/src/pages/workshop/admin/index.tsx +++ b/apps/docs/src/pages/workshop/admin/index.tsx @@ -3,12 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; + export default function WorkshopAdminRoute() { const { siteConfig } = useDocusaurusContext(); const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; return ( - + ); } diff --git a/apps/docs/src/pages/workshop/index.tsx b/apps/docs/src/pages/workshop/index.tsx index f7fadabf8..e7b8e3a82 100644 --- a/apps/docs/src/pages/workshop/index.tsx +++ b/apps/docs/src/pages/workshop/index.tsx @@ -5,6 +5,7 @@ import Layout from "@theme/Layout"; import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp"; import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; export default function WorkshopPage() { const { siteConfig } = useDocusaurusContext(); @@ -16,7 +17,7 @@ export default function WorkshopPage() { }> {() => ( - + )} diff --git a/apps/docs/static/workshop-runtime-config.js b/apps/docs/static/workshop-runtime-config.js new file mode 100644 index 000000000..6fd1814b0 --- /dev/null +++ b/apps/docs/static/workshop-runtime-config.js @@ -0,0 +1 @@ +globalThis.homarrRuntimeConfig ??= Object.freeze({}); diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile index cbae362db..b48eb2509 100644 --- a/apps/workshop/Dockerfile +++ b/apps/workshop/Dockerfile @@ -18,8 +18,10 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set FROM dependencies AS website ARG HOMARR_WEBSITE_URL=https://homarr.dev ARG WORKSHOP_API_URL=https://homarr.dev +ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL ENV WORKSHOP_API_URL=$WORKSHOP_API_URL +ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime @@ -34,6 +36,12 @@ EXPOSE 8090 HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1 FROM pocketbase-runtime AS production +ARG HOMARR_WEBSITE_URL=https://homarr.dev +ARG WORKSHOP_API_URL=https://homarr.dev +ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop +ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL +ENV WORKSHOP_API_URL=$WORKSHOP_API_URL +ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL COPY --from=website /app/apps/docs/build /pb_public COPY apps/workshop/pb_migrations /pb_migrations COPY apps/workshop/pb_hooks /pb_hooks diff --git a/apps/workshop/README.md b/apps/workshop/README.md index a66208514..09807d893 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -70,7 +70,8 @@ pnpm dev `WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable -remains a deprecated alias for one release. +remains a deprecated alias for one release. The production image writes these values into the browser runtime +configuration at container startup, so changing them requires a restart but not an image rebuild. ## Optional GitHub OAuth diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml index c6eac0034..350538104 100644 --- a/apps/workshop/docker-compose.yml +++ b/apps/workshop/docker-compose.yml @@ -33,9 +33,12 @@ services: args: HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003} + WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} PORT: ${DOCS_EXPOSE_PORT:-3003} + WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003} WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} diff --git a/apps/workshop/entrypoint.sh b/apps/workshop/entrypoint.sh index 84a04ade4..31d204ed7 100644 --- a/apps/workshop/entrypoint.sh +++ b/apps/workshop/entrypoint.sh @@ -6,6 +6,24 @@ if [ "$(id -u)" -eq 0 ]; then exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint' fi +HOMARR_WEBSITE_URL=${HOMARR_WEBSITE_URL:-https://homarr.dev} +WORKSHOP_API_URL=${WORKSHOP_API_URL:-$HOMARR_WEBSITE_URL} +WORKSHOP_WEB_URL=${WORKSHOP_WEB_URL:-$HOMARR_WEBSITE_URL/workshop} + +encode_runtime_value() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +cat >/pb_public/workshop-runtime-config.js < + new TextDecoder().decode(Uint8Array.from(atob(value), (character) => character.charCodeAt(0))); +globalThis.homarrRuntimeConfig = Object.freeze({ + homarrWebsiteUrl: decodeRuntimeValue("$(encode_runtime_value "$HOMARR_WEBSITE_URL")"), + workshopApiUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_API_URL")"), + workshopWebUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_WEB_URL")") +}); +EOF + exec pocketbase serve \ --http="0.0.0.0:${PORT:-8090}" \ --origins="${PB_ALLOWED_ORIGINS:-*}" \ diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 7219fe970..ed4d59333 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -54,4 +54,15 @@ for (const required of [ const workflow = await read(".github/workflows/workshop.yml"); if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs"); +const entrypoint = await read("apps/workshop/entrypoint.sh"); +if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) { + throw new Error("Workshop must publish its API URL when the container starts"); +} + +const docsConfig = await read("apps/docs/docusaurus.config.ts"); +if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) { + throw new Error("Workshop must load the container runtime configuration before the documentation bundle"); +} +await access(resolve("apps/docs/static/workshop-runtime-config.js")); + console.log("Workshop static contracts passed"); From 8f75954ca987cc7db8e0b82c6c2cbbdde9298469 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 21:17:41 +0200 Subject: [PATCH 2/4] fix: prevent horizontal rail auto-scroll --- .../board/sections/grid/grid-editor.tsx | 2 +- e2e/board-grid.spec.ts | 41 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index d4d0782ab..ea80787b3 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -1324,7 +1324,7 @@ const configureEditorPlugins = (defaults: Plugins) => dropAnimation: null, keyboardTransition: { duration: 100 }, }); - if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } }); + if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } }); return plugin; }); diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 1be6bdb9b..8a5926205 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -253,6 +253,38 @@ describe("Board grid", () => { expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe( "Left dashboard rail", ); + const railGrid = rail.locator("[data-grid-section-id]").first(); + const railSnapshot = await readEditorGridSnapshotAsync(railGrid); + await rail.evaluate((element) => { + const overflowFixture = document.createElement("div"); + overflowFixture.dataset.e2eRailOverflowFixture = "true"; + overflowFixture.style.position = "absolute"; + overflowFixture.style.insetInlineStart = "100%"; + overflowFixture.style.width = "500px"; + overflowFixture.style.height = "1px"; + overflowFixture.style.pointerEvents = "none"; + element.appendChild(overflowFixture); + }); + await expect + .poll(async () => await rail.evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeGreaterThan(0); + const railItemBox = await expectBoundingBoxAsync(railItem); + const railDragStart = { + x: railItemBox.x + railItemBox.width / 2, + y: railItemBox.y + railItemBox.height / 2, + }; + await page.mouse.move(railDragStart.x, railDragStart.y); + await page.mouse.down(); + await page.mouse.move(railDragStart.x + 8, railDragStart.y); + await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true"); + await page.mouse.move(railBox.x + railBox.width - 2, railDragStart.y, { steps: 12 }); + await page.waitForTimeout(250); + expect(await rail.evaluate((element) => element.scrollLeft)).toBe(0); + expect((await expectBoundingBoxAsync(rail)).x).toBeCloseTo(railBox.x, 1); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); + await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await logicalTile.evaluate((element) => { @@ -566,9 +598,8 @@ describe("Board grid", () => { }); const containerGrip = containerSection.locator("[data-grid-container-drag-handle]"); await expectTargetSizeAsync(containerGrip); - const railEditor = rail.locator("[data-grid-section-id]").first(); - const railGridBox = await expectBoundingBoxAsync(railEditor); - await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railEditor, 0, 0, canvasScale); + const railGridBox = await expectBoundingBoxAsync(railGrid); + await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railGrid, 0, 0, canvasScale); await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", @@ -677,7 +708,7 @@ describe("Board grid", () => { await railKeyboardEntry.focus(); await railKeyboardEntry.press("Enter"); const mixedInputSnapshot = await readEditorGridSnapshotAsync(mainGrid); - const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railEditor); + const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railGrid); const mixedInputHandleBox = await expectBoundingBoxAsync(eastResizeHandle); await page.mouse.move( mixedInputHandleBox.x + mixedInputHandleBox.width / 2, @@ -690,7 +721,7 @@ describe("Board grid", () => { await page.keyboard.press("Escape"); await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(mixedInputSnapshot); - await expect.poll(async () => await readEditorGridSnapshotAsync(railEditor)).toEqual(mixedInputRailSnapshot); + await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(mixedInputRailSnapshot); await expectTouchResizeCancellationRestoresAsync( page, mainGrid, From 8a3f8d5c128276344d3003da0860f289d62ff646 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 21:21:01 +0200 Subject: [PATCH 3/4] fix: allow Workshop runtime config writes --- apps/workshop/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile index b48eb2509..b90e488c3 100644 --- a/apps/workshop/Dockerfile +++ b/apps/workshop/Dockerfile @@ -28,7 +28,7 @@ FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime ENV PORT=8090 RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint -RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data +RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public ENTRYPOINT ["workshop-entrypoint"] FROM pocketbase-runtime AS development From 7cf27c3f39aa4d8467c53ce628a6ec8e78109328 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 21:40:20 +0200 Subject: [PATCH 4/4] feat: add Workshop staging deployment --- .github/workflows/workshop.yml | 3 +- .../advanced/environment-variables/index.mdx | 4 +- apps/docs/docs/management/workshop/index.mdx | 36 +++++++++++++ apps/workshop/.env.staging.example | 12 +++++ apps/workshop/README.md | 43 ++++++++++++++++ apps/workshop/docker-compose.staging.yml | 25 ++++++++++ apps/workshop/docker-compose.yml | 2 + apps/workshop/test-image.sh | 50 +++++++++++++++++++ apps/workshop/test.sh | 11 +++- .../tests/runtime-config.integration.mjs | 24 +++++++++ package.json | 1 + 11 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 apps/workshop/.env.staging.example create mode 100644 apps/workshop/docker-compose.staging.yml create mode 100644 apps/workshop/test-image.sh create mode 100644 apps/workshop/tests/runtime-config.integration.mjs diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index ec6d8d9dc..c924e8d80 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -23,8 +23,9 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @homarr/workshop typecheck - run: pnpm --filter @homarr/docs typecheck + - run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet - run: pnpm test:workshop - - run: docker build --target production -f apps/workshop/Dockerfile . + - run: pnpm test:workshop-image publish: if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main') diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 9caa898a5..e3afc40b0 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w | Environment Variable | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. | -| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. | -| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. | +| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. | +| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. | All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 472111f22..e9d4acd2c 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -112,6 +112,42 @@ The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it. +### Staging and demo deployment + +Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2 +preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Start or update it from the repository root: + +```sh +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml pull +docker compose --env-file apps/workshop/.env.staging \ + -f apps/workshop/docker-compose.staging.yml up --detach +curl --fail http://127.0.0.1:8090/api/health +``` + +The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including +`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading. +This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the +chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep +`/workshop` only on `WORKSHOP_WEB_URL`. + +The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable +for previews; use the `sha-` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows +self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead. +GitHub OAuth requires both credentials and the callback +`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health +endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview. + Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. diff --git a/apps/workshop/.env.staging.example b/apps/workshop/.env.staging.example new file mode 100644 index 000000000..c783f53bc --- /dev/null +++ b/apps/workshop/.env.staging.example @@ -0,0 +1,12 @@ +WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2 +WORKSHOP_EXPOSE_PORT=8090 + +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* + +# Optional GitHub OAuth application for the preview hostname. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/apps/workshop/README.md b/apps/workshop/README.md index 09807d893..4818567c5 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -162,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev PB_ALLOWED_ORIGINS=* ``` +### Deploy a v2 staging or demo instance + +The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to +the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to +run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then: + +```sh +cp .env.staging.example .env.staging +docker compose --env-file .env.staging -f docker-compose.staging.yml pull +docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach +docker compose --env-file .env.staging -f docker-compose.staging.yml ps +curl --fail http://127.0.0.1:8090/api/health +``` + +The checked-in example is ready for `https://v2.preview.homarr.dev`: + +```dotenv +HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev +WORKSHOP_API_URL=https://v2.preview.homarr.dev +WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop +WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev +PB_ALLOWED_ORIGINS=* +``` + +Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and +`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and +PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead +and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`). + +The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a +new v2 image while retaining the named PocketBase volume. Use the immutable `sha-` image tag when promoting a +tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins +call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins. + +After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is +enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and +restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr +preview before sharing the demo. + Build and smoke-test the combined image locally: ```sh @@ -172,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs ``` +`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the +browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin +browser access all work. + Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional, irreversible local reset: diff --git a/apps/workshop/docker-compose.staging.yml b/apps/workshop/docker-compose.staging.yml new file mode 100644 index 000000000..a4604e4c2 --- /dev/null +++ b/apps/workshop/docker-compose.staging.yml @@ -0,0 +1,25 @@ +name: homarr-workshop-staging + +services: + workshop: + image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2} + restart: unless-stopped + environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file} + PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} + PORT: 8090 + WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file} + WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file} + WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + ports: + - "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090" + volumes: + - pb_data:/pb_data + security_opt: + - no-new-privileges:true + stop_grace_period: 30s + +volumes: + pb_data: diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml index 350538104..22c1be2e6 100644 --- a/apps/workshop/docker-compose.yml +++ b/apps/workshop/docker-compose.yml @@ -8,8 +8,10 @@ services: target: development restart: unless-stopped environment: + HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003} PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*} PORT: ${PB_EXPOSE_PORT:-8090} + WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090} WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} diff --git a/apps/workshop/test-image.sh b/apps/workshop/test-image.sh new file mode 100644 index 000000000..c56e3b15e --- /dev/null +++ b/apps/workshop/test-image.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091} +WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$" +WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$" +PREVIEW_ORIGIN=https://v2.preview.homarr.dev + +cleanup() { + docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" . +docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \ + --publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \ + --env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + --env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + --env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \ + --env PB_ALLOWED_ORIGINS='*' \ + "$WORKSHOP_IMAGE_TEST_TAG" >/dev/null + +for attempt in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$WORKSHOP_IMAGE_TEST_NAME" + exit 1 + fi + sleep 1 +done + +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + node apps/workshop/tests/runtime-config.integration.mjs + +curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null +curl --fail --silent \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null +curl --fail --silent --dump-header - --output /dev/null \ + --header 'Origin: https://homarr.example' \ + "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \ + | tr -d '\r' \ + | grep -qi '^Access-Control-Allow-Origin: \*$' + +echo "Workshop production image passed" diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh index b010a45ca..67a28d080 100644 --- a/apps/workshop/test.sh +++ b/apps/workshop/test.sh @@ -14,7 +14,10 @@ cleanup() { } trap cleanup EXIT -PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ +HOMARR_WEBSITE_URL=https://preview.example.invalid \ + WORKSHOP_API_URL=https://api.preview.example.invalid \ + WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ -f apps/workshop/docker-compose.yml up --build -d for attempt in $(seq 1 60); do @@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do sleep 1 done +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \ + EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \ + EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \ + EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ + node apps/workshop/tests/runtime-config.integration.mjs + docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \ pocketbase superuser create \ workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data diff --git a/apps/workshop/tests/runtime-config.integration.mjs b/apps/workshop/tests/runtime-config.integration.mjs new file mode 100644 index 000000000..469d5d12c --- /dev/null +++ b/apps/workshop/tests/runtime-config.integration.mjs @@ -0,0 +1,24 @@ +import vm from "node:vm"; + +const baseUrl = process.env.WORKSHOP_TEST_URL; +if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required"); + +const response = await fetch(`${baseUrl}/workshop-runtime-config.js`); +if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`); + +const context = { TextDecoder, Uint8Array, atob }; +context.globalThis = context; +vm.runInNewContext(await response.text(), context); + +const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig)); +const expected = { + homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL, + workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL, + workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL, +}; + +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`); +} + +console.log("Workshop runtime configuration passed"); diff --git a/package.json b/package.json index 6857153a0..e5683d34c 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", "test:workshop": "sh apps/workshop/test.sh", + "test:workshop-image": "sh apps/workshop/test-image.sh", "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled", "typecheck": "turbo typecheck", "with-env": "dotenv -e .env --"