diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7baef3af..53f765b9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -22,16 +22,22 @@ concurrency: group: docker-${{ github.ref }} cancel-in-progress: true +env: + PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }} + REGISTRY: ghcr.io + permissions: contents: read packages: write id-token: write jobs: - docker: - runs-on: ubuntu-latest + build: strategy: + fail-fast: false matrix: + image: [server, agent, web, mcp] + platform: [linux/amd64, linux/arm64] include: - image: server dockerfile: docker/Dockerfile.server @@ -41,10 +47,114 @@ jobs: dockerfile: docker/Dockerfile.web - image: mcp dockerfile: docker/Dockerfile.mcp + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Go + if: matrix.image == 'server' || matrix.image == 'mcp' + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Pre-warm Go mod cache + if: matrix.image == 'server' || matrix.image == 'mcp' + run: | + mkdir -p .go-cache + GOMODCACHE=$(pwd)/.go-cache go mod download + + - name: Login to Docker Hub + if: env.PUSH == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: env.PUSH == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + outputs: ${{ env.PUSH == 'true' && format('type=image,"name={0}/{1}/{2}",push-by-digest=true,name-canonical=true,push=true,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image) || '' }} + build-contexts: ${{ (matrix.image == 'server' || matrix.image == 'mcp') && format('gomodcache={0}/.go-cache', github.workspace) || '' }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT_HASH=${{ github.sha }} + VITE_API_URL=/api + VITE_AGENT_URL=/agent + cache-from: | + type=gha,scope=${{ matrix.image }}-${{ matrix.platform }} + type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + cache-to: | + type=gha,scope=${{ matrix.image }}-${{ matrix.platform }},mode=max + ${{ env.PUSH == 'true' && format('type=registry,ref={0}/{1}/{2}:buildcache-{3},mode=max,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image, matrix.platform == 'linux/amd64' && 'amd64' || 'arm64') || '' }} + + - name: Export digest + if: env.PUSH == 'true' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + if: env.PUSH == 'true' + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image }}-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') + needs: build + strategy: + matrix: + image: [server, agent, web, mcp] + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-${{ matrix.image }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -59,50 +169,12 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha - labels: | - org.opencontainers.image.title=memoh-${{ matrix.image }} - org.opencontainers.image.description=Memoh ${{ matrix.image }} - Multi-member AI agent platform - org.opencontainers.image.vendor=memohai - - name: Set up QEMU - if: startsWith(github.ref, 'refs/tags/') - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ${{ matrix.dockerfile }} - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - COMMIT_HASH=${{ github.sha }} - BUILD_TIME=${{ fromJson(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - VITE_API_URL=/api - VITE_AGENT_URL=/agent - provenance: ${{ startsWith(github.ref, 'refs/tags/') }} - sbom: ${{ startsWith(github.ref, 'refs/tags/') }} - cache-from: type=gha,scope=${{ matrix.image }} - cache-to: type=gha,scope=${{ matrix.image }},mode=max + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}@sha256:%s ' *) + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} diff --git a/README.md b/README.md index a699b940..7565030b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Forks Last Commit Issues + Docker
[Telegram Group] diff --git a/README_CN.md b/README_CN.md index 6fea3dbb..fa006709 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,6 +15,7 @@ Forks Last Commit Issues + Docker
[Telegram 群组] diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index 891a64b7..48407f4b 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -1,4 +1,5 @@ -FROM oven/bun:1 AS builder +# syntax=docker/dockerfile:1 +FROM --platform=$BUILDPLATFORM oven/bun:1 AS builder WORKDIR /build diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp index 065decf1..2de72f1f 100644 --- a/docker/Dockerfile.mcp +++ b/docker/Dockerfile.mcp @@ -1,9 +1,16 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS build +FROM scratch AS gomodcache + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \ + set -eux; \ + if [ -d /tmp/gomodcache/cache/download ]; then \ + cp -a /tmp/gomodcache/. /go/pkg/mod/; \ + fi; \ go mod download COPY . . diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 8d16ba06..06df6301 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -1,37 +1,33 @@ # syntax=docker/dockerfile:1 -# ---- Stage 1: Build server binary ---- -FROM golang:1.25-alpine AS server-builder +# ---- Stage 0: Cache Context Fallback ---- +FROM scratch AS gomodcache +# ---- Stage 1: Build base with dependencies ---- +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build-base WORKDIR /build RUN apk add --no-cache git make - COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \ + set -eux; \ + if [ -d /tmp/gomodcache/cache/download ]; then \ + cp -a /tmp/gomodcache/. /go/pkg/mod/; \ + fi; \ go mod download - COPY . . +# ---- Stage 2: Build server binary ---- +FROM build-base AS server-builder ARG VERSION=dev ARG COMMIT_HASH=unknown ARG BUILD_TIME=unknown ARG TARGETOS ARG TARGETARCH - RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ set -eux; \ - build_os="${TARGETOS:-linux}"; \ - build_arch="${TARGETARCH:-$(uname -m)}"; \ - case "$build_arch" in \ - x86_64) build_arch="amd64" ;; \ - aarch64) build_arch="arm64" ;; \ - esac; \ - case "$build_arch" in \ - amd64|arm64) ;; \ - *) echo "unsupported TARGETARCH: $build_arch (only amd64/arm64)"; exit 1 ;; \ - esac; \ - CGO_ENABLED=0 GOOS="$build_os" GOARCH="$build_arch" \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ go build -trimpath \ -ldflags "-s -w \ -X github.com/memohai/memoh/internal/version.Version=${VERSION} \ @@ -39,23 +35,13 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \ -o memoh-server ./cmd/agent/main.go -# ---- Stage 2: Build MCP binary ---- -FROM golang:1.25-alpine AS mcp-builder - -WORKDIR /src -RUN apk add --no-cache ca-certificates git - -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod \ - go mod download - -COPY . . - -ARG TARGETARCH=amd64 +# ---- Stage 3: Build MCP binary ---- +FROM build-base AS mcp-builder +ARG TARGETARCH ARG COMMIT_HASH=unknown RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \ go build -trimpath \ -ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" \ -o /out/mcp ./cmd/mcp @@ -88,14 +74,15 @@ FROM alpine:latest AS oci-exporter COPY --from=mcp-rootfs /tmp/rootfs.tar /tmp/layer.tar ARG MCP_IMAGE_TAG=docker.io/library/memoh-mcp:latest +ARG TARGETARCH RUN set -e \ && LAYER_SHA=$(sha256sum /tmp/layer.tar | awk '{print $1}') \ && LAYER_SIZE=$(wc -c < /tmp/layer.tar) \ && mkdir -p "/tmp/image/${LAYER_SHA}" /out \ && mv /tmp/layer.tar "/tmp/image/${LAYER_SHA}/layer.tar" \ - && printf '{"architecture":"amd64","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \ - "${LAYER_SHA}" > /tmp/config.json \ + && printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \ + "${TARGETARCH:-amd64}" "${LAYER_SHA}" > /tmp/config.json \ && CONFIG_SHA=$(sha256sum /tmp/config.json | awk '{print $1}') \ && mv /tmp/config.json "/tmp/image/${CONFIG_SHA}.json" \ && printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar"]}]' \ diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index cec58def..758127f2 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:25-alpine AS builder +FROM --platform=$BUILDPLATFORM node:25-alpine AS builder WORKDIR /build diff --git a/internal/healthcheck/checkers/mcp/checker.go b/internal/healthcheck/checkers/mcp/checker.go index 0145bc13..1de61551 100644 --- a/internal/healthcheck/checkers/mcp/checker.go +++ b/internal/healthcheck/checkers/mcp/checker.go @@ -152,7 +152,7 @@ func (c *Checker) ListChecks(ctx context.Context, botID string) []healthcheck.Ch prefix := sanitizeToolPrefix(conn.Name) toolCount := 0 if prefix != "" { - toolPrefix := prefix + "." + toolPrefix := prefix + "_" for _, tool := range tools { if strings.HasPrefix(strings.TrimSpace(tool.Name), toolPrefix) { toolCount++ diff --git a/internal/healthcheck/checkers/mcp/checker_test.go b/internal/healthcheck/checkers/mcp/checker_test.go index 068d3a0b..0535fe97 100644 --- a/internal/healthcheck/checkers/mcp/checker_test.go +++ b/internal/healthcheck/checkers/mcp/checker_test.go @@ -51,8 +51,8 @@ func TestCheckerListChecks(t *testing.T) { }, &fakeToolLister{ items: []mcp.ToolDescriptor{ - {Name: "hello_world.ping"}, - {Name: "hello_world.echo"}, + {Name: "hello_world_ping"}, + {Name: "hello_world_echo"}, }, }, )