From d356728ed52b29f19849bd402f10c3a1ae1bc206 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sat, 18 Jul 2026 15:59:52 +0900 Subject: [PATCH] perf: reuse release artifacts for container images --- .github/workflows/ci.yml | 13 +--- .github/workflows/release.yml | 42 ++++++---- .gitignore | 1 + Dockerfile | 46 +++-------- Makefile | 22 +++++- docker-compose.integration.yml | 5 +- guide/development.md | 3 +- guide/docker.md | 11 ++- guide/ko/development.md | 3 +- guide/ko/docker.md | 11 ++- internal/archtest/release_contract_test.go | 91 ++++++++++++++++++---- scripts/integration-test.sh | 5 ++ 12 files changed, 174 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8335dee..a5c5f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: verify: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -39,18 +39,13 @@ jobs: - name: Run go test run: CGO_ENABLED=1 go test -tags "fts5" ./... -count=1 - - name: Build release binaries - run: make build - - - name: Build Wiki UI - run: make wiki-build + - name: Prepare container artifacts + run: make container-artifacts - name: Build production image run: | docker build \ - --build-arg VERSION=ci \ - --build-arg COMMIT=${{ github.sha }} \ - --build-arg DATE=unknown \ + --build-arg TARGETARCH=amd64 \ --tag ccg:ci . - name: Capture go build JSON diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf6bc69..20135ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,11 @@ jobs: goos: darwin goarch: amd64 artifact: ccg-darwin-amd64 - - os: ubuntu-latest + - os: ubuntu-24.04 goos: linux goarch: amd64 artifact: ccg-linux-amd64 - - os: ubuntu-latest + - os: ubuntu-24.04 goos: linux goarch: arm64 artifact: ccg-linux-arm64 @@ -162,6 +162,32 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Download Linux AMD64 artifact + uses: actions/download-artifact@v4 + with: + name: ccg-linux-amd64 + path: artifacts/ccg-linux-amd64 + + - name: Download Linux ARM64 artifact + uses: actions/download-artifact@v4 + with: + name: ccg-linux-arm64 + path: artifacts/ccg-linux-arm64 + + - name: Download Wiki artifact + uses: actions/download-artifact@v4 + with: + name: ccg-wiki-dist + path: artifacts/ccg-wiki-dist + + - name: Prepare container artifacts + shell: bash + run: | + mkdir -p container-artifacts/amd64 container-artifacts/arm64 container-artifacts/wiki + tar xzf artifacts/ccg-linux-amd64/ccg-linux-amd64.tar.gz -C container-artifacts/amd64 + tar xzf artifacts/ccg-linux-arm64/ccg-linux-arm64.tar.gz -C container-artifacts/arm64 + tar xzf artifacts/ccg-wiki-dist/ccg-wiki-dist.tar.gz -C container-artifacts/wiki + - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -187,14 +213,6 @@ jobs: type=semver,pattern={{major}} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - - name: Prepare container build metadata - id: build-meta - shell: bash - run: | - echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - - name: Build and publish container image uses: docker/build-push-action@v7 with: @@ -203,10 +221,6 @@ jobs: push: true tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} - build-args: | - VERSION=${{ steps.build-meta.outputs.version }} - COMMIT=${{ steps.build-meta.outputs.commit }} - DATE=${{ steps.build-meta.outputs.date }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index bbf731c..1ac273e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ pyproject.toml *.db-wal /web/wiki/dist/ /web/wiki/node_modules/ +/container-artifacts/ search-retrieval.md _workspace/ diff --git a/Dockerfile b/Dockerfile index ee021b6..1afc514 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,19 @@ -# Wiki UI build stage -FROM node:22-alpine AS wiki-builder +# Runtime stage packages binaries and Wiki assets prepared by the release workflow. +FROM ubuntu:24.04 -WORKDIR /src/web/wiki -COPY web/wiki/package.json web/wiki/package-lock.json ./ -RUN npm ci -COPY web/wiki/ ./ -RUN npm run build +ARG TARGETARCH=amd64 -# Build stage -FROM golang:1.25-alpine AS builder - -RUN apk add --no-cache gcc musl-dev git - -ARG VERSION=dev -ARG COMMIT=unknown -ARG DATE=unknown - -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /usr/local/bin/ccg ./cmd/ccg/ \ - && CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /usr/local/bin/ccg-server ./cmd/ccg-server/ - -# Runtime stage -FROM alpine:3.21 - -RUN apk add --no-cache ca-certificates git \ - && addgroup -S ccg \ - && adduser -S -G ccg -h /home/ccg ccg \ +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y ca-certificates git wget \ + && groupadd --system ccg \ + && useradd --system --gid ccg --create-home --home-dir /home/ccg ccg \ && mkdir -p /repo /repos /home/ccg/.cache /home/ccg/.config/ccg \ - && chown -R ccg:ccg /repo /repos /home/ccg + && chown -R ccg:ccg /repo /repos /home/ccg \ + && rm -rf /var/lib/apt/lists/* -COPY --from=builder /usr/local/bin/ccg /usr/local/bin/ccg -COPY --from=builder /usr/local/bin/ccg-server /usr/local/bin/ccg-server -COPY --from=wiki-builder /src/web/wiki/dist /usr/share/ccg/wiki +COPY container-artifacts/${TARGETARCH}/ccg /usr/local/bin/ccg +COPY container-artifacts/${TARGETARCH}/ccg-server /usr/local/bin/ccg-server +COPY container-artifacts/wiki /usr/share/ccg/wiki WORKDIR /repo USER ccg diff --git a/Makefile b/Makefile index 0c7acb5..ff9645b 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,10 @@ WIKI_ADDR ?= 127.0.0.1:8080 WIKI_DB ?= ccg.db WIKI_REPO ?= . WIKI_TOKEN ?= +HOST_GOOS := $(shell go env GOOS) +CONTAINER_ARCH ?= $(shell go env GOARCH) -.PHONY: build release build-debug build-json install vet test test-integration-helpers wiki-build wiki-db wiki-docs wiki-run wiki-run-indexed clean +.PHONY: build release build-debug build-json install vet test test-integration-helpers wiki-build wiki-db wiki-docs wiki-run wiki-run-indexed container-artifacts clean build: release @@ -39,6 +41,24 @@ test-integration-helpers: wiki-build: cd web/wiki && npm ci && npm run build +container-artifacts: wiki-build + mkdir -p container-artifacts/$(CONTAINER_ARCH) container-artifacts/wiki + if [ "$(HOST_GOOS)" = "linux" ]; then \ + $(MAKE) release; \ + else \ + docker run --rm --platform linux/$(CONTAINER_ARCH) \ + --user "$$(id -u):$$(id -g)" \ + -e GOCACHE=/tmp/go-build \ + -e VERSION="$(VERSION)" \ + -e COMMIT="$(COMMIT)" \ + -e DATE="$(DATE)" \ + -v "$$(pwd):/src" -w /src golang:1.25-bookworm \ + sh -c 'CGO_ENABLED=1 go build -tags "fts5" -ldflags "-s -w -X main.version=$$VERSION -X main.commit=$$COMMIT -X main.date=$$DATE" -o ccg ./cmd/ccg/ && CGO_ENABLED=1 go build -tags "fts5" -ldflags "-s -w -X main.version=$$VERSION -X main.commit=$$COMMIT -X main.date=$$DATE" -o ccg-server ./cmd/ccg-server/'; \ + fi + cp ccg container-artifacts/$(CONTAINER_ARCH)/ccg + cp ccg-server container-artifacts/$(CONTAINER_ARCH)/ccg-server + cp -R web/wiki/dist/. container-artifacts/wiki/ + wiki-db: CGO_ENABLED=1 go run -tags "fts5" ./cmd/ccg --db-driver sqlite --db-dsn '$(WIKI_DB)' migrate CGO_ENABLED=1 go run -tags "fts5" ./cmd/ccg --db-driver sqlite --db-dsn '$(WIKI_DB)' build '$(WIKI_REPO)' diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index 8815f18..0d69255 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -1,5 +1,6 @@ # Full-stack integration test: Gitea + PostgreSQL + ccg -# Usage: docker compose -f docker-compose.integration.yml up -d +# Usage: make container-artifacts +# CONTAINER_ARCH=$(go env GOARCH) docker compose -f docker-compose.integration.yml up -d # ./scripts/integration-test.sh # docker compose -f docker-compose.integration.yml down -v @@ -54,6 +55,8 @@ services: build: context: . dockerfile: Dockerfile + args: + TARGETARCH: ${CONTAINER_ARCH:-amd64} environment: - CCG_DB_DRIVER=postgres - CCG_DB_DSN=host=postgres user=ccg password=ccg dbname=ccg_integration port=5432 sslmode=disable diff --git a/guide/development.md b/guide/development.md index 820c0f2..33d9551 100644 --- a/guide/development.md +++ b/guide/development.md @@ -85,7 +85,8 @@ MCP initialization and tool responses are strict: malformed JSON, top-level JSON ### Manual Container Management ```bash -docker compose -f docker-compose.integration.yml up -d --build +make container-artifacts +CONTAINER_ARCH="$(go env GOARCH)" docker compose -f docker-compose.integration.yml up -d --build docker compose -f docker-compose.integration.yml down -v ``` diff --git a/guide/docker.md b/guide/docker.md index bfad2e8..1fd7ca9 100644 --- a/guide/docker.md +++ b/guide/docker.md @@ -15,9 +15,15 @@ Each stable release publishes the full semantic version, minor, major, and ## Build Image ```bash -docker build -t ccg . +make container-artifacts +docker build --build-arg TARGETARCH="$(go env GOARCH)" -t ccg . ``` +`make container-artifacts` compiles the Linux binaries and Wiki UI once, then +the Dockerfile packages those prepared assets without compiling source. Linux +hosts use the local Go toolchain; other hosts compile the matching Linux +architecture inside `golang:1.25-bookworm`. + ## Run as MCP Server ```bash @@ -177,6 +183,7 @@ docker compose up -d The full-stack pipeline test can also be run with Docker Compose. See the [Development Guide](development.md#integration-test) for details. ```bash -docker compose -f docker-compose.integration.yml up -d --build +make container-artifacts +CONTAINER_ARCH="$(go env GOARCH)" docker compose -f docker-compose.integration.yml up -d --build docker compose -f docker-compose.integration.yml down -v ``` diff --git a/guide/ko/development.md b/guide/ko/development.md index 4146399..09a7e0b 100644 --- a/guide/ko/development.md +++ b/guide/ko/development.md @@ -87,7 +87,8 @@ MCP 초기화 및 도구 응답은 엄격하게 체크됩니다: 잘못된 형 ### 수동 컨테이너 관리 ```bash -docker compose -f docker-compose.integration.yml up -d --build +make container-artifacts +CONTAINER_ARCH="$(go env GOARCH)" docker compose -f docker-compose.integration.yml up -d --build docker compose -f docker-compose.integration.yml down -v ``` diff --git a/guide/ko/docker.md b/guide/ko/docker.md index 652e8a7..b9487a7 100644 --- a/guide/ko/docker.md +++ b/guide/ko/docker.md @@ -5,9 +5,15 @@ ## 이미지 빌드 (Build Image) ```bash -docker build -t ccg . +make container-artifacts +docker build --build-arg TARGETARCH="$(go env GOARCH)" -t ccg . ``` +`make container-artifacts`는 Linux 바이너리와 Wiki UI를 한 번만 빌드하고, +Dockerfile은 준비된 결과물만 패키징합니다. Linux 호스트에서는 로컬 Go +도구체인을 사용하며, 그 외 호스트에서는 `golang:1.25-bookworm` 컨테이너에서 +대상 Linux 아키텍처용 바이너리를 컴파일합니다. + ## MCP 서버로 실행 (Run as MCP Server) ```bash @@ -151,6 +157,7 @@ docker compose up -d 전체 파이프라인 테스트 또한 Docker Compose로 실행할 수 있습니다. 자세한 내용은 [개발 가이드](development.md#integration-test)를 참조하십시오. ```bash -docker compose -f docker-compose.integration.yml up -d --build +make container-artifacts +CONTAINER_ARCH="$(go env GOARCH)" docker compose -f docker-compose.integration.yml up -d --build docker compose -f docker-compose.integration.yml down -v ``` diff --git a/internal/archtest/release_contract_test.go b/internal/archtest/release_contract_test.go index 08f2258..ddbae6a 100644 --- a/internal/archtest/release_contract_test.go +++ b/internal/archtest/release_contract_test.go @@ -9,22 +9,32 @@ import ( "go.yaml.in/yaml/v3" ) -func TestDockerfileInjectsReleaseMetadataIntoBothBinaries(t *testing.T) { +func TestDockerfilePackagesPrebuiltReleaseArtifacts(t *testing.T) { dockerfile := readRepositoryFile(t, "Dockerfile") for _, contract := range []string{ - "ARG VERSION=dev", - "ARG COMMIT=unknown", - "ARG DATE=unknown", + "FROM ubuntu:24.04", + "ARG TARGETARCH=amd64", + "ca-certificates git wget", + "COPY container-artifacts/${TARGETARCH}/ccg /usr/local/bin/ccg", + "COPY container-artifacts/${TARGETARCH}/ccg-server /usr/local/bin/ccg-server", + "COPY container-artifacts/wiki /usr/share/ccg/wiki", } { if !strings.Contains(dockerfile, contract) { - t.Errorf("Dockerfile missing release metadata contract %q", contract) + t.Errorf("Dockerfile missing artifact packaging contract %q", contract) } } - const linkerFlags = `-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}` - if count := strings.Count(dockerfile, linkerFlags); count != 2 { - t.Errorf("Dockerfile release linker flags count = %d, want 2 (ccg and ccg-server)", count) + for _, forbidden := range []string{ + "FROM node:", + "FROM golang:", + "go build", + "npm ci", + "npm run build", + } { + if strings.Contains(dockerfile, forbidden) { + t.Errorf("Dockerfile must package prebuilt artifacts, found %q", forbidden) + } } } @@ -54,6 +64,16 @@ func TestReleaseWorkflowPublishesVersionedMultiPlatformImage(t *testing.T) { } for _, contract := range []string{ + "os: ubuntu-24.04", + "name: ccg-linux-amd64", + "path: artifacts/ccg-linux-amd64", + "name: ccg-linux-arm64", + "path: artifacts/ccg-linux-arm64", + "name: ccg-wiki-dist", + "path: artifacts/ccg-wiki-dist", + "tar xzf artifacts/ccg-linux-amd64/ccg-linux-amd64.tar.gz -C container-artifacts/amd64", + "tar xzf artifacts/ccg-linux-arm64/ccg-linux-arm64.tar.gz -C container-artifacts/arm64", + "tar xzf artifacts/ccg-wiki-dist/ccg-wiki-dist.tar.gz -C container-artifacts/wiki", "docker/login-action@v4", "docker/metadata-action@v6", "docker/setup-qemu-action@v4", @@ -68,9 +88,6 @@ func TestReleaseWorkflowPublishesVersionedMultiPlatformImage(t *testing.T) { "type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}", "platforms: linux/amd64,linux/arm64", "push: true", - "VERSION=${{ steps.build-meta.outputs.version }}", - "COMMIT=${{ steps.build-meta.outputs.commit }}", - "DATE=${{ steps.build-meta.outputs.date }}", } { if !strings.Contains(workflow, contract) { t.Errorf("release workflow missing container publication contract %q", contract) @@ -82,11 +99,11 @@ func TestCIWorkflowBuildsProductionImageWithoutPublishing(t *testing.T) { workflow := readRepositoryFile(t, ".github", "workflows", "ci.yml") for _, contract := range []string{ + "name: Prepare container artifacts", + "make container-artifacts", "name: Build production image", "docker build", - "--build-arg VERSION=ci", - "--build-arg COMMIT=${{ github.sha }}", - "--build-arg DATE=unknown", + "--build-arg TARGETARCH=amd64", } { if !strings.Contains(workflow, contract) { t.Errorf("CI workflow missing build-only image contract %q", contract) @@ -97,6 +114,52 @@ func TestCIWorkflowBuildsProductionImageWithoutPublishing(t *testing.T) { } } +func TestContainerArtifactsTargetPreparesBuiltBinariesAndWiki(t *testing.T) { + makefile := readRepositoryFile(t, "Makefile") + + for _, contract := range []string{ + "HOST_GOOS", + "container-artifacts: wiki-build", + "if [ \"$(HOST_GOOS)\" = \"linux\" ]; then", + "$(MAKE) release", + "docker run --rm --platform linux/$(CONTAINER_ARCH)", + "container-artifacts/$(CONTAINER_ARCH)", + "container-artifacts/wiki", + "cp ccg container-artifacts/$(CONTAINER_ARCH)/ccg", + "cp ccg-server container-artifacts/$(CONTAINER_ARCH)/ccg-server", + "cp -R web/wiki/dist/. container-artifacts/wiki/", + } { + if !strings.Contains(makefile, contract) { + t.Errorf("Makefile missing container artifact preparation contract %q", contract) + } + } +} + +func TestIntegrationStackPreparesArtifactsForItsTargetArchitecture(t *testing.T) { + compose := readRepositoryFile(t, "docker-compose.integration.yml") + for _, contract := range []string{ + "TARGETARCH: ${CONTAINER_ARCH:-amd64}", + } { + if !strings.Contains(compose, contract) { + t.Errorf("integration compose file missing artifact packaging contract %q", contract) + } + } + + script := readRepositoryFile(t, "scripts", "integration-test.sh") + for _, contract := range []string{ + "CONTAINER_ARCH=${CONTAINER_ARCH:-$(go env GOARCH)}", + "make container-artifacts", + "compose build ccg", + } { + if !strings.Contains(script, contract) { + t.Errorf("integration script missing artifact preparation contract %q", contract) + } + } + if strings.Index(script, "make container-artifacts") > strings.Index(script, "compose build ccg") { + t.Error("integration script must prepare artifacts before building the ccg image") + } +} + func readRepositoryFile(t *testing.T, pathParts ...string) string { t.Helper() path := filepath.Join(append([]string{repositoryRoot(t)}, pathParts...)...) diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 60d49f8..9c23502 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -3,6 +3,8 @@ set -euo pipefail COMPOSE_FILE="docker-compose.integration.yml" COMPOSE_CMD=${COMPOSE_CMD:-"docker compose"} +CONTAINER_ARCH=${CONTAINER_ARCH:-$(go env GOARCH)} +export CONTAINER_ARCH GITEA_URL="http://localhost:3000" CCG_URL="http://localhost:18080" ADMIN_USER="testadmin" @@ -104,6 +106,9 @@ wait_for_postgres() { } start_integration_stack() { + info "Preparing ccg container artifacts for linux/${CONTAINER_ARCH}..." + make container-artifacts + info "Starting base services..." compose up -d postgres gitea