Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ jobs:
- run: CELLD_PACKAGE_INTEGRATION=1 bun run test:package:prebuilt
- run: bun run test:celld
- run: NATIVE_WORKER_CELLD=1 bun test scripts/native-worker cloudflare/native-worker.test.ts --timeout 180000
- name: Test native Worker apps on a two-node celld fleet
run: |
docker run --rm -d --name artifacts-fleet-test-s3 -p 127.0.0.1:5117:9000 \
-e MINIO_ROOT_USER=artifacts-fixture -e MINIO_ROOT_PASSWORD=artifacts-fixture-secret \
cgr.dev/chainguard/minio:latest server /data --address :9000
trap 'docker stop artifacts-fleet-test-s3' EXIT
NATIVE_WORKER_FLEET=1 bun test scripts/native-worker/fleet.test.ts --timeout 240000

executable:
strategy:
Expand Down Expand Up @@ -97,11 +104,31 @@ jobs:
env:
ARTIFACTS_DOCKER_IMAGE: artifacts:test
run: bun test scripts/docker.integration.test.ts --timeout 240000
- name: Build fleet publisher
uses: docker/build-push-action@v6
with:
context: .
target: fleet-operator
platforms: ${{ matrix.platform }}
load: true
tags: artifacts:fleet-test
cache-from: type=gha,scope=docker-${{ matrix.arch }}
- name: Test authenticated host release command
env:
FLEET_OPERATOR_DOCKER_IMAGE: artifacts:fleet-test
run: bun test scripts/native-worker/fleet-image.test.ts --timeout 30000

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
strategy:
matrix:
include:
- target: runtime
prefix: ""
- target: fleet-operator
prefix: fleet-
timeout-minutes: 30
permissions:
contents: read
Expand All @@ -121,13 +148,15 @@ jobs:
id: metadata
with:
images: ghcr.io/sidequery/artifacts
flavor: prefix=${{ matrix.prefix }}
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: .
target: ${{ matrix.target }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
Expand All @@ -137,7 +166,7 @@ jobs:
type=gha,scope=docker-arm64
- name: Verify anonymous access
env:
IMAGE: ghcr.io/sidequery/artifacts:sha-${{ github.sha }}
IMAGE: ghcr.io/sidequery/artifacts:${{ matrix.prefix }}sha-${{ github.sha }}
run: |
mkdir -p "$RUNNER_TEMP/public-docker"
docker --config "$RUNNER_TEMP/public-docker" manifest inspect "$IMAGE" > /dev/null || {
Expand Down
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,23 @@ COPY patches ./patches
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build:cloudflare --minify && bun run scripts/prepare-container.ts \
&& bun build scripts/native-worker/fleet.ts --target=bun --outfile=dist/container/fleet.js \
&& find dist/worker-app -name '*.map' -delete \
&& mkdir -p /state

FROM oven/bun:1.4.0 AS fleet-operator
WORKDIR /app
COPY --from=build /src/dist/container/bin/ /usr/local/bin/
COPY --from=build /src/dist/container/fleet.js /app/fleet.js
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_BIN=/usr/local/bin/celld CELLD_ESBUILD=/usr/local/bin/esbuild
USER 65532:65532
EXPOSE 4792
STOPSIGNAL SIGTERM
ENTRYPOINT ["bun", "/app/fleet.js"]

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" \
Expand Down
51 changes: 51 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions cloudflare/celld.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,33 @@ celldTest("gallery subscriptions deliver MCP writes on celld and reconnect after
} finally { reconnected.close(); }
}, 120_000);

celldTest("gallery HTTP events deliver changes on celld without a public WebSocket upgrade", async () => {
const abort = new AbortController();
const response = await fetch(`${baseUrl}/api/gallery/events`, { signal: abort.signal });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/event-stream");
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
async function receive(value: string) {
let received = "";
while (!received.includes(`data: ${value}\n\n`)) {
const chunk = await reader.read();
if (chunk.done) throw new Error("Live event stream ended");
received += chunk.value;
}
}
try {
await receive("ready");
const changed = receive("changed");
await withClient(async client => {
const result = await client.callTool({ name: "script_write", arguments: { name: "http-live-gallery", contents: 'export default { fetch() { return new Response("hello"); } };' } });
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
});
await changed;
const denied = await fetch(`${baseUrl}/api/gallery/events`, { headers: { Origin: "https://untrusted.example" } });
expect(denied.status).toBe(403);
} finally { abort.abort(); await reader.cancel().catch(() => {}); }
}, 30_000);

celldTest("runs native artifact SQLite and KV across code update and celld restart", async () => {
await withClient(async client => {
await write(client, serverV1);
Expand Down
60 changes: 60 additions & 0 deletions cloudflare/gallery-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** Bridge native subscriptions to HTTP for proxies that cannot carry upgrades. */
export async function galleryEvents(
request: Request,
subscribe: () => Promise<Response>,
): Promise<Response> {
const response = await subscribe();
const socket = response.webSocket;
if (response.status !== 101 || !socket)
return new Response("Live updates unavailable", { status: 503 });
const encoder = new TextEncoder();
let heartbeat: ReturnType<typeof setInterval>;
let lease: ReturnType<typeof setTimeout>;
let stopped = false;
let controller: ReadableStreamDefaultController<Uint8Array>;
const close = () => {
if (stopped) return;
stopped = true;
clearInterval(heartbeat);
clearTimeout(lease);
request.signal.removeEventListener("abort", close);
socket.close(1000, "Renew subscription");
controller.close();
};
const stream = new ReadableStream<Uint8Array>({
start(output) {
controller = output;
socket.addEventListener("message", (event) => {
if (!stopped && event.data === "changed")
controller.enqueue(encoder.encode("data: changed\n\n"));
});
socket.addEventListener("close", close);
socket.addEventListener("error", close);
socket.accept();
controller.enqueue(encoder.encode("retry: 1000\ndata: ready\n\n"));
// Stay below common proxy idle timeouts; periodically reauthenticate.
heartbeat = setInterval(() => {
if (!stopped) controller.enqueue(encoder.encode(": heartbeat\n\n"));
}, 20_000);
lease = setTimeout(close, 4 * 60_000);
request.signal.addEventListener("abort", close, { once: true });
if (request.signal.aborted) close();
},
cancel() {
// The reader already owns closure when it cancels the response.
if (stopped) return;
stopped = true;
clearInterval(heartbeat);
clearTimeout(lease);
request.signal.removeEventListener("abort", close);
socket.close(1000, "Client disconnected");
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "private, no-store",
"X-Accel-Buffering": "no",
},
});
}
Loading
Loading