From a1e9673d9ae1f07965bc2b2d3a282c968ffcba1c Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 18:18:22 +0800 Subject: [PATCH] ci: add Gitea package workflow --- .dockerignore | 18 +++ .gitea/workflows/package.yml | 211 ++++++++++++++++++++++++++++ .github/workflows/build-package.yml | 128 ----------------- AGENTS.md | 38 +++++ Dockerfile | 7 +- infra/docker/docker-compose.yml | 3 +- 6 files changed, 272 insertions(+), 133 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/package.yml delete mode 100644 .github/workflows/build-package.yml create mode 100644 AGENTS.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..75b8f60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.gitea +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +build/ +dist/ +package/ +temp/ +data/ +db_inp/ +inp/ +.env +*.pyc +*.dump diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml new file mode 100644 index 0000000..86907da --- /dev/null +++ b/.gitea/workflows/package.yml @@ -0,0 +1,211 @@ +name: Server CI/CD + +on: + push: + tags: + - "v*" + - "latest" + workflow_dispatch: {} + +jobs: + docker-image: + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + defaults: + run: + shell: bash + + steps: + - name: Checkout code + env: + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + GIT_USERNAME: ${{ github.actor }} + GIT_TOKEN: ${{ github.token }} + run: | + case "$SERVER_URL" in + http://*) + AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}" + ;; + https://*) + AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}" + ;; + *) + AUTH_SERVER_URL="$SERVER_URL" + ;; + esac + + if [ ! -d .git ]; then + git init . + fi + + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + else + git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git" + fi + + git fetch --depth=1 origin "$COMMIT_SHA" + git checkout --force --detach FETCH_HEAD + git clean -ffdx + + - name: Normalize image metadata + env: + RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} + RAW_REPOSITORY: ${{ github.repository }} + RAW_REF_NAME: ${{ github.ref_name }} + run: | + RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -z "${RAW_REGISTRY_HOST}" ]; then + echo "Missing required repository variable: REGISTRY_HOST" + exit 1 + fi + + REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}" + REGISTRY_HOST="${REGISTRY_HOST#https://}" + REGISTRY_HOST="${REGISTRY_HOST%/}" + + if [ -z "${REGISTRY_HOST}" ]; then + echo "Repository variable REGISTRY_HOST resolves to an empty host" + exit 1 + fi + + REPOSITORY_PATH="${RAW_REPOSITORY#/}" + IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" + IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}" + IMAGE_TAG="${RAW_REF_NAME}" + { + echo "REGISTRY_HOST=${REGISTRY_HOST}" + echo "REPOSITORY_PATH=${REPOSITORY_PATH}" + echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}" + echo "IMAGE_NAME=${IMAGE_NAME}" + echo "IMAGE_TAG=${IMAGE_TAG}" + echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}" + } >> "$GITHUB_ENV" + + - name: Login to Gitea Container Registry + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + if [ -z "${REGISTRY_HOST:-}" ]; then + echo "Missing resolved environment value: REGISTRY_HOST" + exit 1 + fi + + if [ -z "${REGISTRY_USERNAME}" ]; then + echo "Missing required repository secret: REGISTRY_USERNAME" + exit 1 + fi + + if [ -z "${REGISTRY_PASSWORD}" ]; then + echo "Missing required repository secret: REGISTRY_PASSWORD" + exit 1 + fi + + echo "Logging into registry host: ${REGISTRY_HOST}" + echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \ + --username "${REGISTRY_USERNAME}" \ + --password-stdin + + - name: Build and Push Image + run: | + if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then + echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG" + exit 1 + fi + + push_with_retry() { + image_ref="$1" + attempt=1 + max_attempts=3 + + while [ "$attempt" -le "$max_attempts" ]; do + if docker push "$image_ref"; then + return 0 + fi + + if [ "$attempt" -eq "$max_attempts" ]; then + return 1 + fi + + echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..." + attempt=$((attempt + 1)) + sleep 10 + done + } + + if [ "${IMAGE_TAG}" = "latest" ]; then + docker build \ + -f ./Dockerfile \ + -t "${IMAGE_NAME}:latest" \ + . + push_with_retry "${IMAGE_NAME}:latest" + else + docker build \ + -f ./Dockerfile \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + . + push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}" + push_with_retry "${IMAGE_NAME}:latest" + fi + + - name: Notify Deploy Server + run: | + post_deploy_webhook() { + label="$1" + payload="$2" + webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}" + token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" + + webhook_url=$(echo "$webhook_url" | xargs) + + echo "[$label] Calling webhook: $webhook_url" + + http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -d "$payload") + + echo "[$label] webhook HTTP status: ${http_code}" + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + return 0 + fi + + echo "[$label] response headers:" + cat /tmp/deploy_headers.txt + echo "[$label] response body:" + cat /tmp/deploy_response.txt + return 1 + } + + PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}" + FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}" + + echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}" + echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}" + if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then + exit 0 + fi + + echo "Primary webhook request failed, retrying with lowercase repo path..." + echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}" + if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then + exit 0 + fi + + echo "Deploy webhook failed after primary and fallback attempts." + exit 1 + + deploy-fallback-log: + runs-on: ubuntu-22.04 + needs: docker-image + if: failure() + steps: + - name: Deployment not triggered + run: echo "Image build/push failed, deployment webhook was not called." diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml deleted file mode 100644 index efdb759..0000000 --- a/.github/workflows/build-package.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Build And Package - -on: - push: - tags: - - "v*" - -jobs: - build-package: - runs-on: ${{ matrix.os }} - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - - steps: - - name: Checkout source - uses: actions/checkout@v5 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install system build tools - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y build-essential - - - name: Install compile dependencies - run: | - python -m pip install --upgrade pip - pip install cython setuptools wheel - - - name: Run Cython compile - run: | - python scripts/compile.py - - - name: Prepare package and archive - run: | - python - <<'PY' - import os - import shutil - import tarfile - import zipfile - import sys - from pathlib import Path - - root = Path.cwd() - package_dir = root / "package" - dist_dir = root / "dist" - - for d in [package_dir, dist_dir]: - if d.exists(): - shutil.rmtree(d) - d.mkdir(parents=True, exist_ok=True) - - # Define directories with compiled artifacts - compile_dirs = ["app/services", "app/native/wndb", "app/algorithms"] - # Global ignore list - ignore_names = { - ".git", - ".github", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - ".venv", - "venv", - "temp", - "tests", - "package", - "dist", - } - - def ignore_func(directory, names): - rel_dir = os.path.relpath(directory, root).replace("\\", "/") - is_in_compile_path = any(rel_dir.startswith(d) for d in compile_dirs) - - ignored = [] - for name in names: - if name in ignore_names or name.endswith(".pyc"): - ignored.append(name) - # Exclude source .py files only in compiled directories - elif is_in_compile_path and name.endswith(".py"): - ignored.append(name) - return ignored - - for item in root.iterdir(): - if item.name in ignore_names: - continue - target = package_dir / item.name - if item.is_dir(): - shutil.copytree(item, target, ignore=ignore_func) - else: - shutil.copy2(item, target) - - # Safety guard: ensure no .github directory remains - github_paths = [p for p in package_dir.rglob(".github") if p.is_dir()] - for p in github_paths: - shutil.rmtree(p, ignore_errors=True) - - sha = os.environ["GITHUB_SHA"] - run_os = os.environ["RUNNER_OS"].lower() - - if run_os == "windows": - archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.zip" - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for f in package_dir.rglob("*"): - if f.is_file(): - zf.write(f, f.relative_to(package_dir)) - else: - archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.tar.gz" - with tarfile.open(archive_path, "w:gz") as tf: - tf.add(package_dir, arcname=".") - - print(f"Archive created: {archive_path}") - PY - shell: bash - - - name: Upload package artifact - uses: actions/upload-artifact@v5 - with: - name: tjwater-server-package-${{ runner.os }} - path: dist/* - retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..34ee773 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`. + +Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed. + +## Build, Test, and Development Commands + +Use the existing conda environment when available: + +```bash +conda run -n server python -m pytest tests/unit tests/auth -q +conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +docker build -t tjwater-server:local . +docker compose -f infra/docker/docker-compose.yml config +``` + +`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion. + +## Coding Style & Naming Conventions + +Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions. + +## Testing Guidelines + +The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`. + +## Commit & Pull Request Guidelines + +History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary. + +Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior. + +## Security & Configuration Tips + +Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials. diff --git a/Dockerfile b/Dockerfile index 0dc2071..757f518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,11 +10,10 @@ COPY requirements.txt . RUN pip install uv RUN uv pip install --system --no-cache-dir -r requirements.txt -# 将代码放入子目录 'app',将数据放入子目录 'db_inp' -# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离 +# 将代码放入子目录 'app',临时数据目录运行时创建。 +# db_inp 和 .env 都不应依赖 Git 跟踪或被烘焙进镜像。 COPY app ./app -COPY db_inp ./db_inp -COPY .env . +RUN mkdir -p ./db_inp # 设置 PYTHONPATH 以便 uvicorn 找到 app 模块 ENV PYTHONPATH=/app diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index abbd2c7..138b374 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -3,9 +3,10 @@ services: # Core API Service # ========================================== api: + image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local} build: context: ../.. - dockerfile: infra/docker/Dockerfile + dockerfile: Dockerfile container_name: tjwater_api restart: always ports: