diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 227b3e35..16e3dfb5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -94,8 +94,13 @@ jobs: sleep 1 done echo "${health}" | jq -e '.ok == true and .runtime == "self-hosted-bun" and .authMode == "required"' + curl --fail --silent --show-error http://127.0.0.1:8787/ | grep -F '' docker exec edgeever-ci test -f /data/edgeever.sqlite docker exec edgeever-ci test -d /data/resources + docker exec edgeever-ci test -s /app/scripts/self-hosted-server.js + docker exec edgeever-ci test ! -e /app/node_modules + docker exec edgeever-ci test ! -e /app/apps/api + docker exec edgeever-ci test ! -e /app/packages - name: Stop container gracefully run: | @@ -201,6 +206,31 @@ jobs: docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" docker buildx imagetools inspect --raw "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" | jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' + docker pull "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" + + container_name=edgeever-published-audit + trap 'docker rm --force "${container_name}" >/dev/null 2>&1 || true' EXIT + docker run --detach --name "${container_name}" \ + --env EDGE_EVER_AUTH_PASSWORD=published-container-audit-password \ + --publish 127.0.0.1:8787:8787 \ + "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}" + + for attempt in $(seq 1 60); do + if health="$(curl --fail --silent --show-error http://127.0.0.1:8787/api/health)"; then + break + fi + sleep 1 + done + echo "${health}" | jq -e '.ok == true and .runtime == "self-hosted-bun" and .authMode == "required"' + curl --fail --silent --show-error http://127.0.0.1:8787/ | grep -F '' + docker exec "${container_name}" test -s /app/scripts/self-hosted-server.js + docker exec "${container_name}" test ! -e /app/node_modules + docker exec "${container_name}" test ! -e /app/apps/api + docker exec "${container_name}" test ! -e /app/packages + docker stop --timeout 30 "${container_name}" + docker logs "${container_name}" | grep -F '[self-hosted] shutdown complete' + docker rm "${container_name}" + trap - EXIT release-audit: if: github.repository == 'tianma-if/edgeever' && github.event_name == 'release' diff --git a/Dockerfile b/Dockerfile index b4a8d379..e77ed1f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,25 +17,21 @@ COPY packages/wrangler/package.json packages/wrangler/package.json COPY patches patches FROM manifests AS dependencies -RUN bun install --frozen-lockfile --linker hoisted \ +RUN bun install --frozen-lockfile \ --filter edgeever \ --filter @edgeever/api \ --filter @edgeever/public-network \ --filter @edgeever/web -FROM manifests AS production-dependencies -RUN bun install --frozen-lockfile --production --linker hoisted \ - --filter edgeever \ - --filter @edgeever/public-network - FROM dependencies AS build COPY apps/api apps/api COPY apps/web apps/web COPY packages packages COPY docs docs COPY release-summary.json release-summary.json +COPY scripts/self-hosted-config.mjs scripts/self-hosted-secrets.mjs scripts/self-hosted-server.mjs scripts/ COPY tsconfig.json tailwind.config.ts ./ -RUN bun run build:web +RUN bun run build:web && bun run build:self-hosted FROM oven/bun:1.3.14-alpine AS runtime WORKDIR /app @@ -46,15 +42,14 @@ ENV NODE_ENV=production \ EDGE_EVER_WEB_DIR=/app/apps/web/dist \ PORT=8787 -COPY --from=production-dependencies /app/node_modules ./node_modules -COPY --from=build /app/apps/api ./apps/api +LABEL org.opencontainers.image.title="EdgeEver" \ + org.opencontainers.image.description="Self-hosted notes and knowledge management" \ + org.opencontainers.image.licenses="AGPL-3.0-only" + +COPY --from=build /app/dist/self-hosted/self-hosted-server.js ./scripts/self-hosted-server.js COPY --from=build /app/apps/web/dist ./apps/web/dist -COPY --from=build /app/packages ./packages -COPY --from=build /app/docs/openapi.json ./docs/openapi.json -COPY --from=build /app/release-summary.json ./release-summary.json COPY migrations ./migrations -COPY scripts/self-hosted-config.mjs scripts/self-hosted-secrets.mjs scripts/self-hosted-server.mjs ./scripts/ -COPY package.json ./package.json +COPY LICENSE ./LICENSE RUN mkdir -p /data && chown -R bun:bun /data USER bun @@ -63,4 +58,4 @@ EXPOSE 8787 STOPSIGNAL SIGTERM HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD bun -e "const r=await fetch('http://127.0.0.1:8787/api/health');if(!r.ok)process.exit(1)" -CMD ["bun", "scripts/self-hosted-server.mjs"] +CMD ["bun", "scripts/self-hosted-server.js"] diff --git a/package.json b/package.json index 74f7122b..0789a96c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "build:android:play:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:play", "dev:site": "bun --cwd apps/site dev", "build": "bun run build:web", + "build:self-hosted": "bun build scripts/self-hosted-server.mjs --target=bun --format=esm --outdir=dist/self-hosted --sourcemap=none", "build:plugin-api": "bun run --cwd packages/plugin-api build", "build:extension": "vite build --config apps/extension/vite.config.ts", "build:extension:firefox": "bun run --cwd apps/extension build:firefox", diff --git a/scripts/docker-release.test.mjs b/scripts/docker-release.test.mjs index 428ed74d..ab7fe96a 100644 --- a/scripts/docker-release.test.mjs +++ b/scripts/docker-release.test.mjs @@ -54,6 +54,10 @@ describe("self-hosted runtime configuration", () => { describe("Docker release contract", () => { test("runs the production image as a non-root user with one persistent volume", () => { const dockerfile = readProjectFile("Dockerfile"); + const runtimeStage = dockerfile.slice( + dockerfile.indexOf("FROM oven/bun:1.3.14-alpine AS runtime"), + ); + const packageMetadata = JSON.parse(readProjectFile("package.json")); expect(dockerfile).toContain("FROM oven/bun:1.3.14-alpine AS runtime"); expect(dockerfile).toContain("COPY patches patches"); expect(dockerfile).toContain("COPY docs docs"); @@ -66,22 +70,32 @@ describe("Docker release contract", () => { expect(dockerfile).toContain( "COPY release-summary.json release-summary.json", ); - expect(dockerfile).toContain( - "COPY --from=build /app/release-summary.json ./release-summary.json", - ); expect(dockerfile).toContain("--filter @edgeever/web"); expect(dockerfile).toContain("--filter @edgeever/public-network"); expect(dockerfile).toContain( - "RUN bun install --frozen-lockfile --linker hoisted \\\n --filter edgeever \\\n --filter @edgeever/api", + "RUN bun install --frozen-lockfile \\\n --filter edgeever \\\n --filter @edgeever/api", ); - expect(dockerfile).toContain( - "--production --linker hoisted \\\n --filter edgeever \\\n --filter @edgeever/public-network", + expect(packageMetadata.scripts["build:self-hosted"]).toBe( + "bun build scripts/self-hosted-server.mjs --target=bun --format=esm --outdir=dist/self-hosted --sourcemap=none", ); + expect(dockerfile).toContain("RUN bun run build:web && bun run build:self-hosted"); + expect(runtimeStage).toContain( + "COPY --from=build /app/dist/self-hosted/self-hosted-server.js ./scripts/self-hosted-server.js", + ); + expect(runtimeStage).toContain('org.opencontainers.image.title="EdgeEver"'); + expect(runtimeStage).toContain('org.opencontainers.image.licenses="AGPL-3.0-only"'); + expect(runtimeStage).not.toContain("node_modules"); + expect(runtimeStage).not.toContain("/app/apps/api"); + expect(runtimeStage).not.toContain("/app/packages"); + expect(runtimeStage).not.toContain("./package.json"); + expect(runtimeStage).not.toContain("./release-summary.json"); + expect(runtimeStage).not.toContain("./docs/openapi.json"); expect(dockerfile).toContain("USER bun"); expect(dockerfile).toContain('VOLUME ["/data"]'); expect(dockerfile).toContain("HEALTHCHECK"); expect(dockerfile).toContain("ARG EDGE_EVER_BUILD_ID=unknown"); expect(dockerfile).toContain("EDGE_EVER_BUILD_ID=${EDGE_EVER_BUILD_ID}"); + expect(runtimeStage).toContain('CMD ["bun", "scripts/self-hosted-server.js"]'); }); test("keeps authentication explicit in Compose", () => { @@ -134,6 +148,12 @@ describe("Docker release contract", () => { expect(workflow).not.toContain("releases/tags/${RELEASE_TAG}"); expect(workflow).toContain("docker logout ghcr.io"); expect(workflow).toContain("docker buildx imagetools inspect"); + expect(workflow).toContain('docker pull "${GHCR_IMAGE_NAME}:${{ steps.image.outputs.audit_tag }}"'); + expect(workflow).toContain("container_name=edgeever-published-audit"); + expect(workflow).toContain("test ! -e /app/node_modules"); + expect(workflow).toContain("test ! -e /app/apps/api"); + expect(workflow).toContain("test ! -e /app/packages"); + expect(workflow).toContain("curl --fail --silent --show-error http://127.0.0.1:8787/"); expect(workflow).toContain('docker build --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}"'); expect(workflow).toContain("EDGE_EVER_BUILD_ID=${{ github.sha }}"); expect(workflow).not.toContain("TCR_IMAGE_NAME");