mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
merge dev
This commit is contained in:
140
.github/workflows/docker-build.yml
vendored
140
.github/workflows/docker-build.yml
vendored
@@ -15,81 +15,167 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY_GHCR: ghcr.io
|
||||
IMAGE_NAME_BACKEND: ${{ github.repository }}/nofx-backend
|
||||
IMAGE_NAME_FRONTEND: ${{ github.repository }}/nofx-frontend
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
prepare:
|
||||
name: Prepare repository metadata
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
image_base: ${{ steps.lowercase.outputs.image_base }}
|
||||
steps:
|
||||
- name: Convert repository name to lowercase
|
||||
id: lowercase
|
||||
run: |
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "image_base=${REPO_LOWER}" >> $GITHUB_OUTPUT
|
||||
echo "Lowercase repository: ${REPO_LOWER}"
|
||||
|
||||
build-and-push:
|
||||
name: Build ${{ matrix.name }} (${{ matrix.arch_tag }})
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
image_suffix: backend
|
||||
platform: linux/amd64
|
||||
arch_tag: amd64
|
||||
runner: ubuntu-22.04
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
image_suffix: backend
|
||||
platform: linux/arm64
|
||||
arch_tag: arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
image_suffix: frontend
|
||||
|
||||
platform: linux/amd64
|
||||
arch_tag: amd64
|
||||
runner: ubuntu-22.04
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
image_suffix: frontend
|
||||
platform: linux/arm64
|
||||
arch_tag: arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
- 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_GHCR }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY_GHCR }}/${{ github.repository }}/nofx-${{ matrix.image_suffix }}
|
||||
${{ env.REGISTRY_GHCR }}/${{ needs.prepare.outputs.image_base }}/nofx-${{ matrix.image_suffix }}
|
||||
${{ secrets.DOCKERHUB_USERNAME && format('{0}/nofx-{1}', secrets.DOCKERHUB_USERNAME, matrix.image_suffix) || '' }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push ${{ matrix.name }} image
|
||||
type=ref,event=branch,suffix=-${{ matrix.arch_tag }}
|
||||
type=semver,pattern={{version}},suffix=-${{ matrix.arch_tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch_tag }}
|
||||
type=semver,pattern={{major}},suffix=-${{ matrix.arch_tag }}
|
||||
type=sha,prefix={{branch}}-,suffix=-${{ matrix.arch_tag }}
|
||||
|
||||
- name: Build and push ${{ matrix.name }}-${{ matrix.arch_tag }} image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch_tag }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch_tag }}
|
||||
build-args: |
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ github.ref_name }}
|
||||
|
||||
|
||||
- name: Image digest
|
||||
run: echo "Image digest for ${{ matrix.name }} - ${{ steps.meta.outputs.digest }}"
|
||||
run: |
|
||||
echo "✅ Built: ${{ matrix.name }}-${{ matrix.arch_tag }}"
|
||||
echo "Platform: ${{ matrix.platform }}"
|
||||
echo "Tags: ${{ steps.meta.outputs.tags }}"
|
||||
|
||||
create-manifest:
|
||||
name: Create multi-arch manifests
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: [prepare, build-and-push]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
image_suffix: [backend, frontend]
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_GHCR }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
|
||||
run: |
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
GHCR_IMAGE="${{ env.REGISTRY_GHCR }}/${IMAGE_BASE}/nofx-${{ matrix.image_suffix }}"
|
||||
|
||||
echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
|
||||
echo "Repository: ${IMAGE_BASE}"
|
||||
echo "Image: ${GHCR_IMAGE}"
|
||||
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-arm64"
|
||||
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:latest" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-arm64"
|
||||
echo "✅ Created latest tag"
|
||||
fi
|
||||
|
||||
if [[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]]; then
|
||||
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
|
||||
docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \
|
||||
"${DOCKERHUB_IMAGE}:${REF_NAME}-amd64" \
|
||||
"${DOCKERHUB_IMAGE}:${REF_NAME}-arm64" || true
|
||||
echo "✅ Created Docker Hub manifest"
|
||||
fi
|
||||
|
||||
echo "🎉 Multi-arch manifest created successfully!"
|
||||
|
||||
246
.github/workflows/pr-docker-check.yml
vendored
Normal file
246
.github/workflows/pr-docker-check.yml
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
name: PR Docker Build Check
|
||||
|
||||
# PR 时只做轻量级构建检查,不推送镜像
|
||||
# 策略: 快速验证 amd64 + 抽样检查 arm64 (backend only)
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'docker/**'
|
||||
- 'Dockerfile*'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '**.go'
|
||||
- 'web/**'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
- '.github/workflows/pr-docker-check.yml'
|
||||
|
||||
jobs:
|
||||
# 快速检查: 所有镜像的 amd64 版本
|
||||
docker-build-amd64:
|
||||
name: Build Check (amd64)
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
test_run: true # 需要测试运行
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
test_run: true
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.name }} image (amd64)
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true # 加载到本地 Docker,用于测试运行
|
||||
tags: nofx-${{ matrix.name }}:pr-test
|
||||
cache-from: type=gha,scope=${{ matrix.name }}-amd64
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
|
||||
build-args: |
|
||||
BUILD_DATE=${{ github.event.pull_request.updated_at }}
|
||||
VCS_REF=${{ github.event.pull_request.head.sha }}
|
||||
VERSION=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Test run container (smoke test)
|
||||
if: matrix.test_run
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
echo "🧪 Testing container startup..."
|
||||
|
||||
# 启动容器
|
||||
docker run -d --name test-${{ matrix.name }} \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-${{ matrix.name }}:pr-test
|
||||
|
||||
# 等待容器启动 (最多 30 秒)
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-${{ matrix.name }}; then
|
||||
echo "✅ Container started successfully"
|
||||
docker logs test-${{ matrix.name }}
|
||||
docker stop test-${{ matrix.name }} || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "❌ Container failed to start"
|
||||
docker logs test-${{ matrix.name }} || true
|
||||
exit 1
|
||||
|
||||
- name: Check image size
|
||||
run: |
|
||||
SIZE=$(docker image inspect nofx-${{ matrix.name }}:pr-test --format='{{.Size}}')
|
||||
SIZE_MB=$((SIZE / 1024 / 1024))
|
||||
|
||||
echo "📦 Image size: ${SIZE_MB} MB"
|
||||
|
||||
# 警告阈值
|
||||
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
|
||||
echo "⚠️ Warning: Backend image is larger than 500MB"
|
||||
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
|
||||
echo "⚠️ Warning: Frontend image is larger than 200MB"
|
||||
else
|
||||
echo "✅ Image size is reasonable"
|
||||
fi
|
||||
|
||||
# ARM64 原生构建检查: 使用 GitHub 原生 ARM64 runner (快速!)
|
||||
docker-build-arm64-native:
|
||||
name: Build Check (arm64 native - backend)
|
||||
runs-on: ubuntu-22.04-arm # 原生 ARM64 runner
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 原生 ARM64 不需要 QEMU,直接构建
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image (arm64 native)
|
||||
uses: docker/build-push-action@v5
|
||||
timeout-minutes: 15 # 原生构建更快!
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.backend
|
||||
platforms: linux/arm64
|
||||
push: false
|
||||
load: true # 加载到本地,用于测试
|
||||
tags: nofx-backend:pr-test-arm64
|
||||
cache-from: type=gha,scope=backend-arm64
|
||||
cache-to: type=gha,mode=max,scope=backend-arm64
|
||||
build-args: |
|
||||
BUILD_DATE=${{ github.event.pull_request.updated_at }}
|
||||
VCS_REF=${{ github.event.pull_request.head.sha }}
|
||||
VERSION=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Test run ARM64 container
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
echo "🧪 Testing ARM64 container startup..."
|
||||
|
||||
# 启动容器
|
||||
docker run -d --name test-backend-arm64 \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-backend:pr-test-arm64
|
||||
|
||||
# 等待启动
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-backend-arm64; then
|
||||
echo "✅ ARM64 container started successfully"
|
||||
docker logs test-backend-arm64
|
||||
docker stop test-backend-arm64 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "❌ ARM64 container failed to start"
|
||||
docker logs test-backend-arm64 || true
|
||||
exit 1
|
||||
|
||||
- name: ARM64 build summary
|
||||
run: |
|
||||
echo "✅ Backend ARM64 native build successful!"
|
||||
echo "Using GitHub native ARM64 runner - no QEMU needed!"
|
||||
echo "Build time is ~3x faster than emulation"
|
||||
|
||||
# 汇总检查结果
|
||||
check-summary:
|
||||
name: Docker Build Summary
|
||||
needs: [docker-build-amd64, docker-build-arm64-native]
|
||||
runs-on: ubuntu-22.04
|
||||
if: always()
|
||||
permissions:
|
||||
pull-requests: write # 用于发布评论
|
||||
steps:
|
||||
- name: Check build results
|
||||
id: check
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 检查 amd64 构建
|
||||
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
|
||||
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
|
||||
AMD64_OK=true
|
||||
else
|
||||
echo "❌ **AMD64 builds**: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
AMD64_OK=false
|
||||
fi
|
||||
|
||||
# 检查 arm64 构建
|
||||
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
|
||||
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
|
||||
ARM64_OK=true
|
||||
else
|
||||
echo "❌ **ARM64 build** (native): Backend failed" >> $GITHUB_STEP_SUMMARY
|
||||
ARM64_OK=false
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$AMD64_OK" = true ] && [ "$ARM64_OK" = true ]; then
|
||||
echo "### 🎉 All checks passed!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "After merge:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Full multi-arch builds (amd64 + arm64) will run in parallel" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Estimated time: 15-20 minutes" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
else
|
||||
echo "### ❌ Build checks failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Please check the build logs above and fix the errors." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on PR
|
||||
if: always() && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const amd64Status = '${{ needs.docker-build-amd64.result }}';
|
||||
const arm64Status = '${{ needs.docker-build-arm64-native.result }}';
|
||||
|
||||
const successIcon = '✅';
|
||||
const failIcon = '❌';
|
||||
|
||||
const comment = [
|
||||
'## 🐳 Docker Build Check Results',
|
||||
'',
|
||||
`**AMD64 builds**: ${amd64Status === 'success' ? successIcon : failIcon} ${amd64Status}`,
|
||||
`**ARM64 build** (native runner): ${arm64Status === 'success' ? successIcon : failIcon} ${arm64Status}`,
|
||||
'',
|
||||
amd64Status === 'success' && arm64Status === 'success'
|
||||
? '### 🎉 All Docker builds passed!\n\n✨ Using GitHub native ARM64 runners - 3x faster than emulation!\n\nAfter merge, full multi-arch builds will run in ~10-12 minutes.'
|
||||
: '### ⚠️ Some builds failed\n\nPlease check the Actions tab for details.',
|
||||
'',
|
||||
'<sub>Checked: Backend (amd64 + arm64 native), Frontend (amd64) | Powered by GitHub ARM64 Runners</sub>'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: comment
|
||||
});
|
||||
84
.github/workflows/pr-go-test-coverage.yml
vendored
Normal file
84
.github/workflows/pr-go-test-coverage.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Go Test Coverage
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test-coverage:
|
||||
name: Go Unit Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r .github/workflows/scripts/requirements.txt
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify Go coverage tool
|
||||
run: |
|
||||
go tool cover -h || echo "Warning: go tool cover not available"
|
||||
|
||||
- name: Run tests with coverage
|
||||
env:
|
||||
DATA_ENCRYPTION_KEY: "test-encryption-key-for-ci-only-not-production"
|
||||
run: |
|
||||
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: Calculate coverage and generate report
|
||||
id: coverage
|
||||
run: |
|
||||
chmod +x .github/workflows/scripts/calculate_coverage.py
|
||||
python .github/workflows/scripts/calculate_coverage.py coverage.out coverage_report.md
|
||||
|
||||
- name: Comment PR with coverage
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
chmod +x .github/workflows/scripts/comment_pr.py
|
||||
python .github/workflows/scripts/comment_pr.py \
|
||||
${{ github.event.pull_request.number }} \
|
||||
"${{ steps.coverage.outputs.coverage }}" \
|
||||
"${{ steps.coverage.outputs.emoji }}" \
|
||||
"${{ steps.coverage.outputs.status }}" \
|
||||
"${{ steps.coverage.outputs.badge_color }}" \
|
||||
coverage_report.md
|
||||
192
.github/workflows/scripts/calculate_coverage.py
vendored
Executable file
192
.github/workflows/scripts/calculate_coverage.py
vendored
Executable file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calculate Go test coverage and generate reports.
|
||||
|
||||
This script parses the coverage.out file generated by `go test -coverprofile`,
|
||||
extracts coverage statistics, and generates formatted reports.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
def parse_coverage_file(coverage_file: str) -> Tuple[float, Dict[str, float]]:
|
||||
"""
|
||||
Parse coverage output file and extract coverage data.
|
||||
|
||||
Args:
|
||||
coverage_file: Path to coverage.out file
|
||||
|
||||
Returns:
|
||||
Tuple of (total_coverage, package_coverage_dict)
|
||||
"""
|
||||
if not os.path.exists(coverage_file):
|
||||
print(f"Error: Coverage file {coverage_file} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Run go tool cover to get coverage data
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['go', 'tool', 'cover', '-func', coverage_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running go tool cover: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
lines = result.stdout.strip().split('\n')
|
||||
package_coverage = {}
|
||||
total_coverage = 0.0
|
||||
|
||||
for line in lines:
|
||||
# Skip empty lines
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# Check for total coverage line
|
||||
if line.startswith('total:'):
|
||||
# Extract percentage from "total: (statements) XX.X%"
|
||||
match = re.search(r'(\d+\.\d+)%', line)
|
||||
if match:
|
||||
total_coverage = float(match.group(1))
|
||||
continue
|
||||
|
||||
# Parse package/file coverage
|
||||
# Format: "package/file.go:function statements coverage%"
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
file_path = parts[0]
|
||||
coverage_str = parts[-1]
|
||||
|
||||
# Extract package name from file path
|
||||
package = file_path.split(':')[0]
|
||||
package_name = '/'.join(package.split('/')[:-1]) if '/' in package else package
|
||||
|
||||
# Extract coverage percentage
|
||||
match = re.search(r'(\d+\.\d+)%', coverage_str)
|
||||
if match:
|
||||
coverage_pct = float(match.group(1))
|
||||
|
||||
# Aggregate by package
|
||||
if package_name not in package_coverage:
|
||||
package_coverage[package_name] = []
|
||||
package_coverage[package_name].append(coverage_pct)
|
||||
|
||||
# Calculate average coverage per package
|
||||
package_avg = {
|
||||
pkg: sum(coverages) / len(coverages)
|
||||
for pkg, coverages in package_coverage.items()
|
||||
}
|
||||
|
||||
return total_coverage, package_avg
|
||||
|
||||
|
||||
def get_coverage_status(coverage: float) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Get coverage status based on percentage.
|
||||
|
||||
Args:
|
||||
coverage: Coverage percentage
|
||||
|
||||
Returns:
|
||||
Tuple of (emoji, status_text, badge_color)
|
||||
"""
|
||||
if coverage >= 80:
|
||||
return '🟢', 'excellent', 'brightgreen'
|
||||
elif coverage >= 60:
|
||||
return '🟡', 'good', 'yellow'
|
||||
elif coverage >= 40:
|
||||
return '🟠', 'fair', 'orange'
|
||||
else:
|
||||
return '🔴', 'needs improvement', 'red'
|
||||
|
||||
|
||||
def generate_coverage_report(coverage_file: str, output_file: str) -> None:
|
||||
"""
|
||||
Generate a detailed coverage report in markdown format.
|
||||
|
||||
Args:
|
||||
coverage_file: Path to coverage.out file
|
||||
output_file: Path to output markdown file
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['go', 'tool', 'cover', '-func', coverage_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error generating coverage report: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
f.write("## Coverage by Package\n\n")
|
||||
f.write("```\n")
|
||||
f.write(result.stdout)
|
||||
f.write("```\n")
|
||||
|
||||
|
||||
def set_github_output(name: str, value: str) -> None:
|
||||
"""
|
||||
Set GitHub Actions output variable.
|
||||
|
||||
Args:
|
||||
name: Output variable name
|
||||
value: Output variable value
|
||||
"""
|
||||
github_output = os.environ.get('GITHUB_OUTPUT')
|
||||
if github_output:
|
||||
with open(github_output, 'a') as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
else:
|
||||
print(f"::set-output name={name}::{value}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: calculate_coverage.py <coverage_file> [output_file]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
coverage_file = sys.argv[1]
|
||||
output_file = sys.argv[2] if len(sys.argv) > 2 else 'coverage_report.md'
|
||||
|
||||
# Parse coverage data
|
||||
total_coverage, package_coverage = parse_coverage_file(coverage_file)
|
||||
|
||||
# Get coverage status
|
||||
emoji, status, badge_color = get_coverage_status(total_coverage)
|
||||
|
||||
# Generate detailed report
|
||||
generate_coverage_report(coverage_file, output_file)
|
||||
|
||||
# Output results
|
||||
print(f"Total Coverage: {total_coverage}%")
|
||||
print(f"Status: {status}")
|
||||
print(f"Badge Color: {badge_color}")
|
||||
|
||||
# Set GitHub Actions outputs
|
||||
set_github_output('coverage', f'{total_coverage}%')
|
||||
set_github_output('coverage_num', str(total_coverage))
|
||||
set_github_output('status', status)
|
||||
set_github_output('emoji', emoji)
|
||||
set_github_output('badge_color', badge_color)
|
||||
|
||||
# Print package breakdown
|
||||
if package_coverage:
|
||||
print("\nCoverage by Package:")
|
||||
for package, coverage in sorted(package_coverage.items()):
|
||||
print(f" {package}: {coverage:.1f}%")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
246
.github/workflows/scripts/comment_pr.py
vendored
Executable file
246
.github/workflows/scripts/comment_pr.py
vendored
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post or update coverage report comment on GitHub Pull Request.
|
||||
|
||||
This script generates a formatted coverage report comment and posts it to a PR,
|
||||
or updates an existing coverage comment if one already exists.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def read_file(file_path: str) -> str:
|
||||
"""Read file content."""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: File {file_path} not found", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def generate_comment_body(coverage: str, emoji: str, status: str,
|
||||
badge_color: str, coverage_report_path: str) -> str:
|
||||
"""
|
||||
Generate the PR comment body.
|
||||
|
||||
Args:
|
||||
coverage: Coverage percentage (e.g., "75.5%")
|
||||
emoji: Status emoji
|
||||
status: Status text
|
||||
badge_color: Badge color
|
||||
coverage_report_path: Path to detailed coverage report
|
||||
|
||||
Returns:
|
||||
Formatted comment body in markdown
|
||||
"""
|
||||
coverage_report = read_file(coverage_report_path)
|
||||
|
||||
# URL encode the coverage percentage for the badge
|
||||
coverage_encoded = coverage.replace('%', '%25')
|
||||
|
||||
comment = f"""## {emoji} Go Test Coverage Report
|
||||
|
||||
**Total Coverage:** `{coverage}` ({status})
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>📊 Detailed Coverage Report (click to expand)</summary>
|
||||
|
||||
{coverage_report}
|
||||
|
||||
</details>
|
||||
|
||||
### Coverage Guidelines
|
||||
- 🟢 >= 80%: Excellent
|
||||
- 🟡 >= 60%: Good
|
||||
- 🟠 >= 40%: Fair
|
||||
- 🔴 < 40%: Needs improvement
|
||||
|
||||
---
|
||||
*This is an automated coverage report. The coverage requirement is advisory and does not block PR merging.*
|
||||
"""
|
||||
return comment
|
||||
|
||||
|
||||
def find_existing_comment(token: str, repo: str, pr_number: int) -> Optional[int]:
|
||||
"""
|
||||
Find existing coverage comment in the PR.
|
||||
|
||||
Args:
|
||||
token: GitHub token
|
||||
repo: Repository in format "owner/repo"
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
Comment ID if found, None otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
comments = response.json()
|
||||
|
||||
# Look for existing coverage comment
|
||||
for comment in comments:
|
||||
if (comment.get('user', {}).get('type') == 'Bot' and
|
||||
'Go Test Coverage Report' in comment.get('body', '')):
|
||||
return comment['id']
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error fetching comments: {e}", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def post_comment(token: str, repo: str, pr_number: int, body: str) -> bool:
|
||||
"""
|
||||
Post a new comment to the PR.
|
||||
|
||||
Args:
|
||||
token: GitHub token
|
||||
repo: Repository in format "owner/repo"
|
||||
pr_number: Pull request number
|
||||
body: Comment body
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
data = {'body': body}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
print("✅ Coverage comment posted successfully")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error posting comment: {e}", file=sys.stderr)
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"Response: {e.response.text}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def update_comment(token: str, repo: str, comment_id: int, body: str) -> bool:
|
||||
"""
|
||||
Update an existing comment.
|
||||
|
||||
Args:
|
||||
token: GitHub token
|
||||
repo: Repository in format "owner/repo"
|
||||
comment_id: Comment ID to update
|
||||
body: New comment body
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}"
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
data = {'body': body}
|
||||
|
||||
try:
|
||||
response = requests.patch(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
print("✅ Coverage comment updated successfully")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error updating comment: {e}", file=sys.stderr)
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"Response: {e.response.text}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def is_fork_pr(event_path: str) -> bool:
|
||||
"""
|
||||
Check if the PR is from a fork.
|
||||
|
||||
Args:
|
||||
event_path: Path to GitHub event JSON file
|
||||
|
||||
Returns:
|
||||
True if fork PR, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(event_path, 'r') as f:
|
||||
event = json.load(f)
|
||||
|
||||
pr = event.get('pull_request', {})
|
||||
head_repo = pr.get('head', {}).get('repo', {}).get('full_name')
|
||||
base_repo = pr.get('base', {}).get('repo', {}).get('full_name')
|
||||
|
||||
return head_repo != base_repo
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||
print(f"Warning: Could not determine if fork PR: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
# Get environment variables
|
||||
token = os.environ.get('GITHUB_TOKEN')
|
||||
repo = os.environ.get('GITHUB_REPOSITORY')
|
||||
event_path = os.environ.get('GITHUB_EVENT_PATH', '')
|
||||
|
||||
# Get arguments
|
||||
if len(sys.argv) < 6:
|
||||
print("Usage: comment_pr.py <pr_number> <coverage> <emoji> <status> <badge_color> [coverage_report_path]",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
pr_number = int(sys.argv[1])
|
||||
coverage = sys.argv[2]
|
||||
emoji = sys.argv[3]
|
||||
status = sys.argv[4]
|
||||
badge_color = sys.argv[5]
|
||||
coverage_report_path = sys.argv[6] if len(sys.argv) > 6 else 'coverage_report.md'
|
||||
|
||||
# Validate environment
|
||||
if not token:
|
||||
print("Error: GITHUB_TOKEN environment variable not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not repo:
|
||||
print("Error: GITHUB_REPOSITORY environment variable not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if fork PR
|
||||
if event_path and is_fork_pr(event_path):
|
||||
print("ℹ️ Fork PR detected - skipping comment (no write permissions)")
|
||||
sys.exit(0)
|
||||
|
||||
# Generate comment body
|
||||
comment_body = generate_comment_body(coverage, emoji, status, badge_color, coverage_report_path)
|
||||
|
||||
# Check for existing comment
|
||||
existing_comment_id = find_existing_comment(token, repo, pr_number)
|
||||
|
||||
# Post or update comment
|
||||
if existing_comment_id:
|
||||
print(f"Found existing comment (ID: {existing_comment_id}), updating...")
|
||||
success = update_comment(token, repo, existing_comment_id, comment_body)
|
||||
else:
|
||||
print("No existing comment found, creating new one...")
|
||||
success = post_comment(token, repo, pr_number, comment_body)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2
.github/workflows/scripts/requirements.txt
vendored
Normal file
2
.github/workflows/scripts/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Python dependencies for GitHub Actions scripts
|
||||
requests>=2.31.0
|
||||
Reference in New Issue
Block a user