Урок 34: CI/CD с GitHub Actions — тесты, линтер, деплой

Урок 34. CI/CD с GitHub Actions — тесты, линтер, деплой

🔄 Node.js → Go (ключевые отличия в CI/CD):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-cicd && cd go-cicd
go mod init go-cicd

# Создаём структуру
mkdir -p cmd/server
mkdir -p internal/handler
mkdir -p .github/workflows

💻 Файл: cmd/server/main.go

package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    fmt.Printf("Server starting on :%s\n", port)
    http.ListenAndServe(":"+port, mux)
}

💻 Файл: internal/handler/handler.go

package handler

func Greet(name string) string {
    if name == "" {
        name = "World"
    }
    return "Hello, " + name + "!"
}

func Add(a, b int) int {
    return a + b
}

💻 Файл: internal/handler/handler_test.go

package handler_test

import (
    "testing"
    "go-cicd/internal/handler"
)

func TestGreet(t *testing.T) {
    tests := []struct {
        name     string
        input    string
        expected string
    }{
        {"with name", "Gopher", "Hello, Gopher!"},
        {"empty name", "", "Hello, World!"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := handler.Greet(tt.input)
            if result != tt.expected {
                t.Errorf("Greet(%q) = %q, want %q", tt.input, result, tt.expected)
            }
        })
    }
}

func TestAdd(t *testing.T) {
    result := handler.Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d, want 5", result)
    }
}

💻 Файл: .golangci.yml (конфигурация линтера)

# Конфигурация golangci-lint (аналог .eslintrc)
run:
  timeout: 5m
  tests: true
  go: '1.22'

linters:
  enable:
    - errcheck      # Проверка необработанных ошибок
    - gosimple      # Упрощение кода
    - govet         # Стандартный анализатор Go
    - ineffassign   # Неиспользуемые присваивания
    - staticcheck   # Статический анализ
    - unused        # Неиспользуемый код
    - gofmt         # Форматирование
    - goimports     # Импорты
    - misspell      # Опечатки
    - revive        # Замена golint
    - unconvert     # Лишние преобразования типов
    - unparam       # Неиспользуемые параметры
    - gocyclo       # Цикломатическая сложность
    - gocognit      # Когнитивная сложность
    - bodyclose     # Проверка закрытия HTTP-ответов

linters-settings:
  gocyclo:
    min-complexity: 15
  gocognit:
    min-complexity: 30
  revive:
    rules:
      - name: exported
        arguments:
          - disableStutteringCheck
  misspell:
    locale: US

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - gocyclo
        - gocognit
  max-issues-per-linter: 0
  max-same-issues: 0

💻 Файл: .github/workflows/ci.yml (основной CI)

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  GO_VERSION: '1.22'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ╔══════════════════════════════════════════════════════════╗
  # ║  JOB 1: ЛИНТЕР + ТЕСТЫ                                 ║
  # ╚══════════════════════════════════════════════════════════╝
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest
    # Matrix: запускаем на нескольких версиях Go
    strategy:
      matrix:
        go-version: ['1.22', '1.21']
      fail-fast: false  # Не останавливать другие при ошибке

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
          cache: true  # Автоматическое кэширование go modules

      - name: Verify dependencies
        run: go mod verify

      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@v4
        with:
          version: latest
          args: --timeout=5m

      - name: Run tests with race detector
        run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.out
          flags: unittests
        continue-on-error: true  # Не фейлить билд если Codecov недоступен

      - name: Run vet
        run: go vet ./...

  # ╔══════════════════════════════════════════════════════════╗
  # ║  JOB 2: СБОРКА DOCKER-ОБРАЗА                           ║
  # ╚══════════════════════════════════════════════════════════╝
  build-and-push:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    needs: lint-and-test  # Только после успешных тестов
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha,prefix=,format=short
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ github.sha }}

  # ╔══════════════════════════════════════════════════════════╗
  # ║  JOB 3: ДЕПЛОЙ В KUBERNETES                            ║
  # ╚══════════════════════════════════════════════════════════╝
  deploy:
    name: Deploy to Kubernetes
    runs-on: ubuntu-latest
    needs: build-and-push
    if: github.ref == 'refs/heads/main'
    environment: production  # Требует approval (если настроен)

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup kubectl
        uses: azure/setup-kubectl@v3

      - name: Set Kubernetes context
        uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBE_CONFIG }}

      - name: Update image in deployment
        run: |
          kubectl set image deployment/go-app \
            go-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
            --namespace=production

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/go-app --namespace=production --timeout=5m

      - name: Verify deployment
        run: |
          kubectl get pods -l app=go-app --namespace=production
          kubectl describe deployment go-app --namespace=production

💻 Файл: .github/workflows/release.yml (релиз)

name: Release

on:
  push:
    tags:
      - 'v*'  # v1.0.0, v2.1.3, etc.

jobs:
  release:
    name: Create Release
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Run tests
        run: go test -v -race ./...

      - name: Build binaries (cross-compile)
        run: |
          GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o dist/app-linux-amd64 ./cmd/server
          GOOS=linux GOARCH=arm64 go build -ldflags="-w -s" -o dist/app-linux-arm64 ./cmd/server
          GOOS=darwin GOARCH=amd64 go build -ldflags="-w -s" -o dist/app-darwin-amd64 ./cmd/server
          GOOS=darwin GOARCH=arm64 go build -ldflags="-w -s" -o dist/app-darwin-arm64 ./cmd/server
          GOOS=windows GOARCH=amd64 go build -ldflags="-w -s" -o dist/app-windows-amd64.exe ./cmd/server

      - name: Create Release
        id: create_release
        uses: softprops/action-gh-release@v1
        with:
          name: Release ${{ github.ref_name }}
          body_path: CHANGELOG.md
          files: dist/*
          generate_release_notes: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

💻 Файл: .github/workflows/dependency-review.yml (безопасность)

name: Dependency Review

on:
  pull_request:

permissions:
  contents: read

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Dependency Review
        uses: actions/dependency-review-action@v4

💻 Файл: Makefile

# Локальные команды (аналогичны CI)

.PHONY: lint test build clean

lint: ## Запустить линтер
	golangci-lint run ./...

test: ## Запустить тесты
	go test -v -race -cover ./...

test-coverage: ## Тесты с покрытием
	go test -v -race -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html

build: ## Собрать бинарник
	CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/server ./cmd/server

clean: ## Очистить
	rm -rf bin/ dist/ coverage.out coverage.html

💻 Файл: Dockerfile

FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/server"]

🚀 Как использовать

# Локальный запуск линтера
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...

# Локальный запуск тестов
go test -v -race ./...

# Сборка Docker-образа
docker build -t go-cicd-app .

# Структура репозитория для GitHub:
# .github/workflows/ci.yml       (основной CI)
# .github/workflows/release.yml  (релизы)
# .golangci.yml                   (конфигурация линтера)
# Dockerfile
# Makefile

# Secrets для GitHub Actions:
# KUBE_CONFIG — kubeconfig для деплоя
# (опционально) CODECOV_TOKEN

📊 CI/CD Pipeline

ЭтапJobТриггерИнструменты
1. Линтlint-and-testpush, PRgolangci-lint
2. Тестыlint-and-testpush, PRgo test -race
3. Сборка образаbuild-and-pushmain branchDocker Buildx
4. Push в registrybuild-and-pushmain branchghcr.io
5. Деплой в K8sdeploymain branchkubectl
6. Релизreleasetag v*GoReleaser / GitHub
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →