From d56d5ee136920227335e8c214cdfb409ebba8326 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 3 Oct 2026 16:42:56 -0700 Subject: [PATCH 1/2] Build and publish compact celld container images --- .dockerignore | 4 ++ .github/workflows/docker.yml | 98 ++++++++++++++++++++++++++++++ Dockerfile | 71 ++++++++-------------- README.md | 50 ++++++++++++++- docs/local-workspace.md | 4 +- e2e/Dockerfile | 48 +++++++++++++++ e2e/herdr.e2e.test.ts | 2 +- scripts/docker.integration.test.ts | 85 ++++++++++++++++++++++++++ scripts/prepare-container.ts | 20 ++++++ 9 files changed, 333 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 e2e/Dockerfile create mode 100644 scripts/docker.integration.test.ts create mode 100644 scripts/prepare-container.ts diff --git a/.dockerignore b/.dockerignore index 047df2f..383ce10 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,10 @@ node_modules *.log .DS_Store .agents +.celld +.wrangler +.dev.vars +.dev.vars.* .env .env.* artifacts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..020b808 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,98 @@ +name: Docker image + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + platform: linux/amd64 + arch: amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: bun install --frozen-lockfile + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + load: true + tags: artifacts:test + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max + - name: Test compilation, persistence, and graceful shutdown + env: + ARTIFACTS_DOCKER_IMAGE: artifacts:test + run: bun test scripts/docker.integration.test.ts --timeout 240000 + + publish: + needs: test + if: github.repository == 'sidequery/artifacts' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: metadata + with: + images: ghcr.io/sidequery/artifacts + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=long + type=ref,event=tag + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: | + type=gha,scope=docker-amd64 + type=gha,scope=docker-arm64 + - name: Verify anonymous access + env: + IMAGE: ghcr.io/sidequery/artifacts:sha-${{ github.sha }} + run: | + mkdir -p "$RUNNER_TEMP/public-docker" + docker --config "$RUNNER_TEMP/public-docker" manifest inspect "$IMAGE" > /dev/null || { + echo 'Set the artifacts container package visibility to Public in GitHub package settings, then rerun this job.' >&2 + exit 1 + } diff --git a/Dockerfile b/Dockerfile index b69f940..477b492 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,28 @@ -FROM debian:bookworm-slim - -ENV DEBIAN_FRONTEND=noninteractive -ENV SHELL=/bin/bash -ENV TERM=xterm-256color -ENV HERDR_E2E=1 -ENV HERDR_SESSION=artifact-e2e -ENV PATH="/usr/local/bin:${PATH}" - -RUN apt-get update && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - libasound2 \ - libgbm1 \ - libgtk-3-0 \ - libnss3 \ - util-linux \ - unzip \ - fonts-liberation \ - && rm -rf /var/lib/apt/lists/* - -RUN curl -fsSL https://bun.sh/install | bash - -RUN curl -fsSL https://herdr.dev/install.sh | sh - -RUN install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun \ - && install -m 0755 /root/.local/bin/herdr /usr/local/bin/herdr \ - && useradd --create-home --shell /bin/bash artifact - -RUN curl -fsSL https://terminal-browser.sh/install \ - | XDG_DATA_HOME=/opt XDG_BIN_HOME=/usr/local/bin AGENT_SKILLS_HOME=/tmp/terminal-browser-skills TERMINAL_BROWSER_SKIP_EDITOR_SETUP=1 bash - +# syntax=docker/dockerfile:1 +FROM oven/bun:1.4.0 AS build WORKDIR /src -COPY package.json bun.lock tsconfig.json herdr-plugin.toml ./ -COPY src ./src -COPY e2e ./e2e -COPY examples ./examples -COPY skills ./skills - +COPY package.json bun.lock ./ +COPY patches ./patches RUN bun install --frozen-lockfile - -ENV HOME=/home/artifact -USER artifact - -CMD ["bun", "e2e/inside.ts"] +COPY . . +RUN bun run build:cloudflare --minify && bun run scripts/prepare-container.ts \ + && find dist/worker-app -name '*.map' -delete \ + && mkdir -p /state + +FROM gcr.io/distroless/cc-debian13:nonroot AS runtime +LABEL org.opencontainers.image.source="https://github.com/sidequery/artifacts" \ + org.opencontainers.image.description="Sidequery Artifacts with celld" \ + org.opencontainers.image.licenses="MIT" +COPY --from=build --chown=65532:65532 /state/ /app/.celld/ +COPY --from=build /src/dist/container/bin/ /usr/local/bin/ +COPY --from=build /src/dist/worker-app/ /app/dist/worker-app/ +COPY --from=build /src/dist/cloudflare/assets/ /app/dist/cloudflare/assets/ +COPY --from=build /src/dist/container/wrangler.jsonc /app/wrangler.jsonc +ENV CELLD_ESBUILD=/usr/local/bin/esbuild CELLD_IDLE_EVICT_S=60 +WORKDIR /app +USER 65532:65532 +VOLUME ["/app/.celld"] +EXPOSE 4786 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/celld"] +CMD ["dev", "/app", "--host", "0.0.0.0", "--port", "4786", "--no-watch", "--logs"] diff --git a/README.md b/README.md index 8ab40ec..1736bf6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,53 @@ Use `artifacts host install` to enable startup at login. See the ## Deploy +### Docker + +The image at `ghcr.io/sidequery/artifacts:latest` supports Linux amd64 and arm64. +It runs celld directly, with the application and native esbuild already included. +Bun is used only during the build. The final distroless image has no Bun, Node, +shell, package manager, or `node_modules` directory and runs as UID/GID 65532. + +```sh +docker run -d --name artifacts --restart unless-stopped \ + --stop-timeout 60 \ + -p 127.0.0.1:4786:4786 \ + -v artifacts-data:/app/.celld \ + ghcr.io/sidequery/artifacts:latest +``` + +Open [localhost:4786](http://127.0.0.1:4786); the MCP endpoint is +`http://127.0.0.1:4786/mcp`. The named volume preserves apps, databases, files, +and schedules across container replacements. Stop the container before backing +up the volume. Allow 60 seconds for graceful shutdown. + +The default command runs single-machine `celld dev` with authentication disabled, +so the example publishes the port on loopback only. For network access, configure +[authentication](docs/authentication.md#configure-celld) in a custom Wrangler +config and mount it at `/app/wrangler.jsonc` (read-only), keeping the image's +`main` and assets paths. Set `ENVIRONMENT` to `production`; environment variables +passed with `docker -e` do not replace Wrangler `vars`. For bucket-backed nodes, +follow the [celld deployment guide](docs/celld-deployment.md). Arguments after the +image name are passed directly to celld; for example, `--help` lists commands. + +Build and test locally: + +```sh +docker build -t artifacts:local . +bun install --frozen-lockfile +ARTIFACTS_DOCKER_IMAGE=artifacts:local bun test scripts/docker.integration.test.ts +``` + +The Docker workflow tests both architectures before publishing to GHCR. Pushes +to `main` publish `latest` and `sha-`; `v*` tags publish the matching +tag and commit tag. Pin a commit tag or image digest for repeatable deployments. +The package is public; the workflow checks anonymous access and fails if the +image is private. When publishing under a different package name, a package +administrator must set its visibility to **Public** in GitHub package settings +after the first push. + +### Cloudflare and celld fleets + Configure sign-in before using a network deployment. The [authentication guide](docs/authentication.md) covers provider setup, Cloudflare Access, MCP OAuth, and troubleshooting. The default local host needs no sign-in. @@ -177,4 +224,5 @@ bun run test:cloudflare Browser tests require `bun x playwright install chromium`. Additional suites cover [MCP Apps and Herdr](docs/local-workspace.md#development-checks), celld (`bun run test:celld`), and package installation (`bun run test:package`). -The root Dockerfile is an integration-test environment. +`e2e/Dockerfile` is the Herdr integration-test environment; the root Dockerfile +builds the standalone celld image. diff --git a/docs/local-workspace.md b/docs/local-workspace.md index 1060003..fd52314 100644 --- a/docs/local-workspace.md +++ b/docs/local-workspace.md @@ -251,9 +251,9 @@ that the pane is owned by `herdr.artifacts`, and proves its server stops when th pane closes. GitHub Actions runs the typecheck and unit/service suite with Bun 1.4.0. -The root Dockerfile is an integration-test environment, not a production image. +`e2e/Dockerfile` is the Herdr integration-test environment. The root Dockerfile +builds the standalone celld image. `bun run test:mcp-ui` exercises the real artifact in Chromium with an MCP Apps host. Install its browser first with `bun x playwright install chromium`. CI runs this browser suite, the unit/service suite, and a clean tarball install that exercises the installed CLI, gallery, compilation, and stdio MCP server. - diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..50a8cb5 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,48 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +ENV SHELL=/bin/bash +ENV TERM=xterm-256color +ENV HERDR_E2E=1 +ENV HERDR_SESSION=artifact-e2e +ENV PATH="/usr/local/bin:${PATH}" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + libasound2 \ + libgbm1 \ + libgtk-3-0 \ + libnss3 \ + util-linux \ + unzip \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://bun.sh/install | bash + +RUN curl -fsSL https://herdr.dev/install.sh | sh + +RUN install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun \ + && install -m 0755 /root/.local/bin/herdr /usr/local/bin/herdr \ + && useradd --create-home --shell /bin/bash artifact + +RUN curl -fsSL https://terminal-browser.sh/install \ + | XDG_DATA_HOME=/opt XDG_BIN_HOME=/usr/local/bin AGENT_SKILLS_HOME=/tmp/terminal-browser-skills TERMINAL_BROWSER_SKIP_EDITOR_SETUP=1 bash + +WORKDIR /src +COPY package.json bun.lock tsconfig.json herdr-plugin.toml ./ +COPY patches ./patches +COPY src ./src +COPY e2e ./e2e +COPY examples ./examples +COPY skills ./skills + +RUN bun install --frozen-lockfile + +ENV HOME=/home/artifact +USER artifact + +CMD ["bun", "e2e/inside.ts"] diff --git a/e2e/herdr.e2e.test.ts b/e2e/herdr.e2e.test.ts index 3d2421d..6f4be8c 100644 --- a/e2e/herdr.e2e.test.ts +++ b/e2e/herdr.e2e.test.ts @@ -15,7 +15,7 @@ test( return; } - const build = Bun.spawn(["docker", "build", "-t", "artifacts-e2e", "."], { + const build = Bun.spawn(["docker", "build", "-f", "e2e/Dockerfile", "-t", "artifacts-e2e", "."], { cwd: PLUGIN_ROOT, stdout: "inherit", stderr: "inherit", diff --git a/scripts/docker.integration.test.ts b/scripts/docker.integration.test.ts new file mode 100644 index 0000000..b08547e --- /dev/null +++ b/scripts/docker.integration.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const image = process.env.ARTIFACTS_DOCKER_IMAGE; + +async function docker(...args: string[]) { + const child = Bun.spawn(["docker", ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + if (code !== 0) throw new Error(`docker ${args.join(" ")} failed: ${stderr}\n${stdout}`); + return stdout.trim(); +} + +(image ? test : test.skip)("container compiles apps and preserves backend data across recreation", async () => { + const name = `artifacts-test-${randomUUID()}`; + const volume = `${name}-data`; + let running = false; + async function start() { + await docker("run", "-d", "--name", name, "--stop-timeout", "60", + "-p", "127.0.0.1::4786", "-v", `${volume}:/app/.celld`, image!); + running = true; + const address = await docker("port", name, "4786/tcp"); + const origin = `http://${address}`; + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + try { + if ((await fetch(`${origin}/health`, { signal: AbortSignal.timeout(1000) })).ok) return origin; + } catch { /* Wait for the container's listener. */ } + if (await docker("inspect", "--format", "{{.State.Running}}", name) !== "true") break; + await Bun.sleep(250); + } + throw new Error(`Container failed readiness:\n${await docker("logs", name)}`); + } + async function stop() { + await docker("stop", name); + expect(await docker("inspect", "--format", "{{.State.ExitCode}}", name)).toBe("0"); + await docker("rm", name); + running = false; + } + await docker("volume", "create", volume); + try { + for (const restart of [false, true]) { + const origin = await start(); + expect((await fetch(origin)).status).toBe(200); + const client = new Client({ name: "docker-test", version: "1" }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp`))); + if (!restart) { + const written = await client.callTool({ name: "artifact_write", arguments: { + name: "persistent-counter", + contents: 'import { H1 } from "sidequery/artifacts"; export default function App() { return

Container app

; }', + server: `import { DurableObject } from "cloudflare:workers"; + export class ArtifactServer extends DurableObject { + fetch(request: Request) { + this.ctx.storage.sql.exec("create table if not exists counter (value integer)"); + if (request.method === "POST") this.ctx.storage.sql.exec("insert into counter values (1)"); + return Response.json({ count: this.ctx.storage.sql.exec("select count(*) as count from counter").one().count }); + } + }`, + } }); + expect(written.isError, JSON.stringify(written)).not.toBe(true); + const version = (written._meta as { artifact: { versionId: string } }).artifact.versionId; + expect((await fetch(`${origin}/gallery/preview?version=${version}`)).status).toBe(200); + } + const result = await client.callTool({ name: "artifact_request", arguments: { + name: "persistent-counter", request: { path: "/", method: restart ? "GET" : "POST", headers: [] }, + } }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const response = (result.structuredContent as { response: { status: number; body: string } }).response; + expect(response.status).toBe(200); + expect(JSON.parse(Buffer.from(response.body, "base64").toString())).toEqual({ count: 1 }); + } finally { await client.close(); } + await stop(); + } + } finally { + if (running) { + console.error(await docker("logs", name)); + await docker("rm", "-f", name); + } + await docker("volume", "rm", volume); + } +}, 240_000); diff --git a/scripts/prepare-container.ts b/scripts/prepare-container.ts new file mode 100644 index 0000000..0439a5c --- /dev/null +++ b/scripts/prepare-container.ts @@ -0,0 +1,20 @@ +import { chmod, copyFile, mkdir } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; +import { ensureCelldRuntime } from "../src/local/celld-runtime"; +import { prepareCelldConfig } from "./prepare-celld"; + +const root = resolve(import.meta.dir, ".."); +const output = join(root, "dist/container"); +await mkdir(join(output, "bin"), { recursive: true }); +// Reuse the release pin and both archive/executable checksums used by the CLI. +const celld = await ensureCelldRuntime({ dataRoot: join(output, "cache") }); +const require = createRequire(import.meta.url); +const esbuildRequire = createRequire(require.resolve("esbuild/package.json")); +const esbuild = esbuildRequire.resolve(`@esbuild/${process.platform}-${process.arch}/bin/esbuild`); +for (const [name, source] of Object.entries({ celld, esbuild })) { + const destination = join(output, "bin", name); + await copyFile(source, destination); + await chmod(destination, 0o755); +} +await prepareCelldConfig(join(root, "wrangler.jsonc"), join(output, "wrangler.jsonc")); From 7e5702b84f82c14b107295e902bb8d7a7cb5afe6 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 3 Oct 2026 17:36:22 -0700 Subject: [PATCH 2/2] Gate container publishing on the main CI pipeline --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++++ .github/workflows/docker.yml | 98 ------------------------------------ README.md | 5 +- cloudflare/worker.test.ts | 29 ++++++----- 4 files changed, 102 insertions(+), 112 deletions(-) delete mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db6009c..f0f568d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,85 @@ jobs: name: artifacts-${{ matrix.os }} path: dist/binaries/* if-no-files-found: error + + docker-test: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + platform: linux/amd64 + arch: amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: bun install --frozen-lockfile + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + load: true + tags: artifacts:test + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max + - name: Test compilation, persistence, and graceful shutdown + env: + ARTIFACTS_DOCKER_IMAGE: artifacts:test + run: bun test scripts/docker.integration.test.ts --timeout 240000 + + docker-publish: + needs: [check, executable, docker-test] + if: github.repository == 'sidequery/artifacts' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: metadata + with: + images: ghcr.io/sidequery/artifacts + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=long + type=ref,event=tag + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: | + type=gha,scope=docker-amd64 + type=gha,scope=docker-arm64 + - name: Verify anonymous access + env: + IMAGE: ghcr.io/sidequery/artifacts:sha-${{ github.sha }} + run: | + mkdir -p "$RUNNER_TEMP/public-docker" + docker --config "$RUNNER_TEMP/public-docker" manifest inspect "$IMAGE" > /dev/null || { + echo 'Set the artifacts container package visibility to Public in GitHub package settings, then rerun this job.' >&2 + exit 1 + } diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 020b808..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Docker image - -on: - pull_request: - push: - branches: [main] - tags: ['v*'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - test: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - platform: linux/amd64 - arch: amd64 - - os: ubuntu-24.04-arm - platform: linux/arm64 - arch: arm64 - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - run: bun install --frozen-lockfile - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - load: true - tags: artifacts:test - cache-from: type=gha,scope=docker-${{ matrix.arch }} - cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max - - name: Test compilation, persistence, and graceful shutdown - env: - ARTIFACTS_DOCKER_IMAGE: artifacts:test - run: bun test scripts/docker.integration.test.ts --timeout 240000 - - publish: - needs: test - if: github.repository == 'sidequery/artifacts' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) - runs-on: ubuntu-24.04 - timeout-minutes: 30 - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: metadata - with: - images: ghcr.io/sidequery/artifacts - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,format=long - type=ref,event=tag - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - cache-from: | - type=gha,scope=docker-amd64 - type=gha,scope=docker-arm64 - - name: Verify anonymous access - env: - IMAGE: ghcr.io/sidequery/artifacts:sha-${{ github.sha }} - run: | - mkdir -p "$RUNNER_TEMP/public-docker" - docker --config "$RUNNER_TEMP/public-docker" manifest inspect "$IMAGE" > /dev/null || { - echo 'Set the artifacts container package visibility to Public in GitHub package settings, then rerun this job.' >&2 - exit 1 - } diff --git a/README.md b/README.md index 1736bf6..17304fa 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,11 @@ bun install --frozen-lockfile ARTIFACTS_DOCKER_IMAGE=artifacts:local bun test scripts/docker.integration.test.ts ``` -The Docker workflow tests both architectures before publishing to GHCR. Pushes +CI builds and tests both image architectures on pushes and pull requests. Image +publishing waits for the application, executable, and Docker checks to pass. Pushes to `main` publish `latest` and `sha-`; `v*` tags publish the matching tag and commit tag. Pin a commit tag or image digest for repeatable deployments. -The package is public; the workflow checks anonymous access and fails if the +The package is public; CI checks anonymous access and fails if the image is private. When publishing under a different package name, a package administrator must set its visibility to **Public** in GitHub package settings after the first push. diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 01bcfdb..09c4adb 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -12,6 +12,13 @@ let runtime: Miniflare; let runtimeOptions: ConstructorParameters[0]; let client: Client; let origin: string; +// Compilation pauses can outlive the local server's idle connections. Avoid +// reusing those sockets in both direct requests and the MCP transport. +const fetch = (input: string | URL | Request, init: RequestInit = {}) => { + const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined)); + headers.set("Connection", "close"); + return globalThis.fetch(input, { ...init, headers }); +}; const source = 'import { Button, H1, Stack, useArtifactState } from "sidequery/artifacts";\nexport default function Artifact() { const [n, setN] = useArtifactState("n", 0); return

Hosted artifact

; }\n'; const counterClient = await readFile(new URL("../examples/counter.artifact.tsx", import.meta.url), "utf8"); const counterServer = await readFile(new URL("../examples/counter.artifact.server.ts", import.meta.url), "utf8"); @@ -55,7 +62,7 @@ beforeAll(async () => { runtime = new Miniflare(runtimeOptions); origin = (await runtime.ready).origin; client = new Client({ name: "artifact-integration", version: "1" }); - await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); + await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`), { fetch })); }, 30000); afterAll(async () => { await client?.close(); await runtime?.dispose(); }); @@ -209,7 +216,7 @@ test("native artifact SQLite works through MCP and the gallery, persists across expect((await callCounter("GET", "other-counter")).value).toBe(0); const otherWorkspace = new Client({ name: "other-workspace", version: "1" }); try { - await otherWorkspace.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=other`))); + await otherWorkspace.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=other`), { fetch })); expect((await otherWorkspace.callTool({ name: "artifact_write", arguments: { name: "counter", contents: counterClient, server: counterServer } })).isError).not.toBe(true); const isolated = await otherWorkspace.callTool({ name: "artifact_request", arguments: { name: "counter", request: { path: "/counter" } } }); expect(isolated.isError).not.toBe(true); @@ -231,13 +238,13 @@ test("native artifact SQLite works through MCP and the gallery, persists across try { const page = await browser.newPage(); await page.goto(`${origin}/?workspace=test`); - await page.getByRole("button", { name: "counter", exact: true }).click(); + await page.getByRole("button", { name: "counter", exact: true }).and(page.getByTitle("test/counter", { exact: true })).click(); const frame = page.frameLocator("iframe"); await frame.getByText("Count: 2", { exact: true }).waitFor(); await frame.getByRole("button", { name: "Increment" }).click(); await frame.getByText("Count: 3", { exact: true }).waitFor(); await page.reload(); - await page.getByRole("button", { name: "counter", exact: true }).click(); + await page.getByRole("button", { name: "counter", exact: true }).and(page.getByTitle("test/counter", { exact: true })).click(); await page.frameLocator("iframe").getByText("Count: 3", { exact: true }).waitFor(); } finally { await browser.close(); } }, 60000); @@ -270,7 +277,12 @@ test("gallery renders interactive sandboxed previews and serves exact archived s await frame.getByRole("button", { name: "Count 1" }).waitFor(); expect(await page.locator("iframe").getAttribute("sandbox")).toBe("allow-scripts"); expect(errors).toEqual([]); - expect(pages).toEqual([0, 100]); + // Opening the live subscription reconciles the gallery again. Each refresh + // must still consume both pages, even when the item is already selected. + expect(pages.slice(0, 2)).toEqual([0, 100]); + for (let index = 0; index < pages.length; index++) { + expect(pages[index]).toBe(index % 2 === 0 ? 0 : 100); + } } finally { await browser.close(); } }, 60000); @@ -291,13 +303,6 @@ test("all hosted surfaces fail closed off loopback, and mutations enforce origin }); test("verified users have isolated private libraries and can collaborate in the team library", async () => { - // Compilation pauses can outlive the local server's idle connections. Avoid - // reusing those sockets in both direct requests and the MCP transport. - const fetch = (input: string | URL | Request, init: RequestInit = {}) => { - const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined)); - headers.set("Connection", "close"); - return globalThis.fetch(input, { ...init, headers }); - }; const { generateKeyPair, exportJWK, SignJWT } = await import("jose"); const { Response: RuntimeResponse } = await import("miniflare"); const keys = await generateKeyPair("RS256", { extractable: true });