Makefile
Rama master · réplica de lectura del repositorio original.
.DEFAULT_GOAL := help
# ─── 变量 ─────────────────────────────────────
APP := depsilo
BIN_DIR := bin
BIN := $(BIN_DIR)/$(APP)
DIST_DIR := dist
WEB_DIR := web
WEB_DIST ?= $(WEB_DIR)/dist
CONFIG := config.toml
DEV_JWT_SECRET ?= .dev-jwt-secret
PID_FILE := .server.pid
DEV_LOG := .dev.log
PORT ?= 23333
DEV_URL ?= http://localhost:$(PORT)
UI_HOST ?= 127.0.0.1
UI_PORT ?= 5173
TEST_DIR := testground
NPM_RUN := npm --prefix $(WEB_DIR) run
VITE_BIN ?= $(WEB_DIR)/node_modules/.bin/vite
GO_PROD_PKGS := ./cmd/... ./internal/... ./web
GO_TEST_PKGS := $(GO_PROD_PKGS) ./tests/unit/... ./tests/mock/...
# Race detection is intentionally concentrated on packages that own goroutines,
# shared mutable state, streaming lifecycles, or background schedulers. The full
# non-race suite still covers every production and cross-module package.
GO_RACE_PKGS := \
./internal/accesslog/... \
./internal/adapter \
./internal/adapter/huggingface/... \
./internal/api/... \
./internal/asyncruntime/... \
./internal/audit/... \
./internal/blocklist/... \
./internal/cache/... \
./internal/compilecache/... \
./internal/config/... \
./internal/db/... \
./internal/distribution/... \
./internal/identity/... \
./internal/license/... \
./internal/middleware/... \
./internal/notify/... \
./internal/quarantine/... \
./internal/rules/... \
./internal/security/... \
./internal/server/... \
./internal/traffic/... \
./internal/trial/... \
./internal/upstream/... \
./internal/upstreamupdates/...
# ─── 版本注入 ─────────────────────────────────
# Match only semver-style tags (v0.2.3) to avoid descriptive tags like
# "portal-redesign-complete" polluting the version pill. Strip leading "v"
# so backend serves clean "0.2.3" — frontend formatVersion() re-prepends
# "v", avoiding "vv0.2.3" double-v.
VERSION ?= $(shell value=$$(git describe --tags --match 'v[0-9]*.[0-9]*.[0-9]*' --always --dirty 2>/dev/null || true); printf '%s\n' "$${value:-dev}" | sed 's/^v//')
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X depsilo/internal/version.Version=$(VERSION) \
-X depsilo/internal/version.Commit=$(COMMIT) \
-X depsilo/internal/version.BuildDate=$(BUILD_DATE)
GO_BUILD := CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags "$(LDFLAGS)"
# ─── 初始化与构建 ─────────────────────────────
.PHONY: setup setup-ui version prepare-go frontend build build-dev-server build-server tray app-macos install-linux uninstall-linux autostart-linux unautostart-linux
setup: ## 安装 Go 与前端开发依赖
go mod download
npm --prefix $(WEB_DIR) ci
setup-ui: setup ## 安装依赖及 Playwright Chromium
cd $(WEB_DIR) && npx --no-install playwright install chromium
version: ## 显示将注入二进制的构建信息
@printf 'version=%s\ncommit=%s\nbuild_date=%s\n' "$(VERSION)" "$(COMMIT)" "$(BUILD_DATE)"
# Go embeds web/dist. Keep backend-only checks usable on a fresh clone without
# forcing a complete frontend build; `frontend` replaces this tiny placeholder.
prepare-go: # 内部:确保 Go embed 目录存在
@bash scripts/prepare-go-embed.sh "$(WEB_DIST)"
frontend: # 内部:构建前端
$(NPM_RUN) build
$(BIN_DIR):
mkdir -p $@
build: frontend | $(BIN_DIR) ## 构建前端 + 编译后端
$(GO_BUILD) -o $(BIN) ./cmd/depsilo
build-dev-server: prepare-go | $(BIN_DIR) # 内部:为 Vite 热更新仅编译后端
$(GO_BUILD) -o $(BIN) ./cmd/depsilo
build-server: frontend | $(BIN_DIR) # 兼容:构建纯服务器入口
$(GO_BUILD) -o $(BIN)-server ./cmd/server
tray: frontend | $(BIN_DIR) ## 构建桌面托盘应用
go build -trimpath -buildvcs=false -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(APP)-tray ./cmd/depsilo-tray
app-macos: tray ## 打 macOS .app bundle(必须在 macOS 上跑)
@bash scripts/build-macos-app.sh "$(VERSION)"
LINUX_ACTION_install-linux := install
LINUX_ACTION_uninstall-linux := uninstall
LINUX_ACTION_autostart-linux := autostart-enable
LINUX_ACTION_unautostart-linux := autostart-disable
install-linux: tray ## Linux:安装桌面应用
uninstall-linux: # 兼容:卸载 Linux 桌面应用
autostart-linux: ## Linux:开启开机自启
unautostart-linux: # 兼容:关闭开机自启
install-linux uninstall-linux autostart-linux unautostart-linux:
@bash scripts/install-linux.sh $(LINUX_ACTION_$@)
# ─── 运行 ─────────────────────────────────────
.PHONY: run run-pro dev dev-ui stop logs
run: build ## 编译并前台运行(自动复用本地开发 JWT)
run-pro: build ## 编译并前台运行(开启全部 Pro 功能)
run-pro: RUN_ENV := DEPSILO_DEV_PRO=1
run run-pro:
@$(RUN_ENV) DEPSILO_DEV_JWT_FILE="$(DEV_JWT_SECRET)" bash scripts/run-dev.sh "./$(BIN)" "$(CONFIG)"
dev: build stop ## 编译并后台运行(dev 模式)
@DEPSILO_DEV_JWT_FILE="$(DEV_JWT_SECRET)" bash scripts/dev-service.sh start \
"./$(BIN)" "$(CONFIG)" "$(PID_FILE)" "$(DEV_LOG)" "$(DEV_URL)" --port "$(PORT)"
dev-ui: build-dev-server ## 同时运行后端与 Vite 热更新(Ctrl-C 一并停止)
@bash scripts/dev-service.sh stop "./$(BIN)" "$(PID_FILE)"
@DEPSILO_DEV_JWT_FILE="$(DEV_JWT_SECRET)" bash scripts/dev-service.sh start \
"./$(BIN)" "$(CONFIG)" "$(PID_FILE)" "$(DEV_LOG)" "$(DEV_URL)" --port "$(PORT)"
@bash scripts/dev-ui.sh scripts/dev-service.sh \
"./$(BIN)" "$(PID_FILE)" "$(DEV_URL)" -- \
"$(VITE_BIN)" "$(WEB_DIR)" --config "$(WEB_DIR)/vite.config.ts" \
--host "$(UI_HOST)" --port "$(UI_PORT)" --strictPort
stop: ## 停止后台 dev 服务
@bash scripts/dev-service.sh stop "./$(BIN)" "$(PID_FILE)"
logs: ## 查看 dev 模式日志
@bash scripts/dev-service.sh logs "$(DEV_LOG)"
# ─── CLI 管理 ────────────────────────────────
.PHONY: cli-status cli-activate cli-warmup cli-flush cli-stop
CLI_ARGS_cli-status := status
CLI_ARGS_cli-activate := activate
CLI_ARGS_cli-warmup := warmup $(ECO) $(PKGS)
CLI_ARGS_cli-flush := flush
CLI_ARGS_cli-stop := stop
cli-status cli-activate cli-warmup cli-flush cli-stop:
@./$(BIN) $(CLI_ARGS_$@)
# ─── SBOM ─────────────────────────────────────
.PHONY: sbom
# Local SBOM generation mirrors what CI emits on a tag push. Useful for
# spot-checking what ships with a release, or for buyers asking "what's
# in the binary?" without waiting for the next release. Requires syft
# (https://github.com/anchore/syft) — installs are one-line.
sbom: # 维护者:生成 SBOM (CycloneDX + SPDX)
@command -v syft >/dev/null 2>&1 || { echo "syft not installed. Install: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin"; exit 1; }
@mkdir -p dist/sbom
@VERSION="$(VERSION)"; \
syft dir:. -o cyclonedx-json="dist/sbom/depsilo-$$VERSION-source.cdx.json" \
-o spdx-json="dist/sbom/depsilo-$$VERSION-source.spdx.json" \
--quiet; \
echo "wrote dist/sbom/depsilo-$$VERSION-source.cdx.json"; \
echo "wrote dist/sbom/depsilo-$$VERSION-source.spdx.json"
# ─── 测试 ─────────────────────────────────────
.PHONY: test test-full test-race test-integration test-ui test-ui-production test-compiler-cache test-compiler-cache-qualified test-s3 test-v090-upgrade test-v090-compose-upgrade test-v091-upgrade
test: prepare-go ## 快速 Go 测试(使用缓存,跳过慢速压力边界)
go test -short $(GO_TEST_PKGS)
test-full: prepare-go ## 完整 Go 测试(全包、非 race、不使用结果缓存)
go test -count=1 $(GO_TEST_PKGS)
test-race: prepare-go ## 对并发与生命周期高风险包运行 race detector
go test -race -count=1 $(GO_RACE_PKGS)
test-integration: prepare-go ## 运行集成测试(启动服务 + mock 上游)
go test ./tests/integration/... -count=1 -timeout 300s -tags integration
test-ui: ## 快速浏览器冒烟测试(首次需安装 Playwright Chromium)
$(NPM_RUN) test:ui:smoke
test-ui-production: build ## 用 Go 嵌入的生产前端运行最小浏览器冒烟测试
$(NPM_RUN) test:ui:production
test-compiler-cache: ## 用官方 ccache + sccache 验证已运行的编译缓存
@bash scripts/test-compiler-cache.sh
test-compiler-cache-qualified: build-dev-server # 发布:自举服务与凭据后运行官方编译缓存客户端
@bash scripts/test-compiler-cache-qualified.sh
test-s3: ## 用固定 RustFS 验证 S3 存储契约
@bash scripts/test-s3.sh
test-v090-upgrade: build-dev-server # 发布:验证 v0.9.0 配置、SQLite、凭据与缓存原地升级
@bash scripts/test-v090-upgrade.sh
test-v090-compose-upgrade: # 发布:验证官方 v0.9.0 Compose bind 布局安全升级
@bash scripts/test-v090-compose-upgrade.sh
test-v091-upgrade: build-dev-server # 发布:验证固定 v0.9.1 source 与已发布镜像状态升级
@bash scripts/test-v091-upgrade.sh
# ─── Docker E2E ──────────────────────────────
# Every fixture accepts one DEPSILO_URL build arg and derives its own route.
# Docker Registry remains opt-in because it additionally requires dind.
DOCKER_HOST_ALIAS ?= host.docker.internal
DEPSILO_URL ?= http://$(DOCKER_HOST_ALIAS):$(PORT)
DOCKER_E2E_BUILD_FLAGS := --no-cache --progress=plain --add-host=$(DOCKER_HOST_ALIAS):host-gateway
TEST_DOCKER_ALL_ECOS := pypi apt npm go cargo maven rubygems composer nuget conda cran alpine helm huggingface
TEST_DOCKER_ALL_TARGETS := $(addprefix test-docker-,$(TEST_DOCKER_ALL_ECOS))
TEST_DOCKER_ECOS := $(TEST_DOCKER_ALL_ECOS) docker
override E2E_CONFIG := config.example.toml
override E2E_STATE_DIR := $(CURDIR)/.test-e2e-state
override E2E_HOME := $(E2E_STATE_DIR)/home
override E2E_DATA_DIR := $(E2E_STATE_DIR)/data
override E2E_DATABASE := $(E2E_DATA_DIR)/depsilo.db
override E2E_STORAGE := $(E2E_DATA_DIR)/cache
override E2E_COMPILE_STORAGE := $(E2E_DATA_DIR)/compile-cache
override E2E_JWT_SECRET := $(E2E_STATE_DIR)/.dev-jwt-secret
override E2E_PID_FILE := $(E2E_STATE_DIR)/.server.pid
override E2E_LOG := $(E2E_STATE_DIR)/.dev.log
.PHONY: test-e2e-server $(TEST_DOCKER_ALL_TARGETS) test-docker-docker test-e2e test-clean
test-e2e-server: build-dev-server # 内部:仅启动真实客户端测试所需的后端
@set -eu; \
bash scripts/e2e-state.sh guard "$(E2E_STATE_DIR)"; \
bash scripts/dev-service.sh stop "./$(BIN)" "$(E2E_PID_FILE)"; \
bash scripts/e2e-state.sh prepare "$(E2E_STATE_DIR)"; \
trap 'bash scripts/dev-service.sh stop "./$(BIN)" "$(E2E_PID_FILE)" >/dev/null 2>&1 || true; bash scripts/e2e-state.sh clean "$(E2E_STATE_DIR)"' EXIT INT TERM; \
HOME="$(E2E_HOME)" DEPSILO_CONFIG="$(E2E_CONFIG)" \
DEPSILO_DEV_JWT_FILE="$(E2E_JWT_SECRET)" \
DEPSILO_DATABASE_DSN="$(E2E_DATABASE)" \
DEPSILO_STORAGE_PATH="$(E2E_STORAGE)" \
DEPSILO_COMPILE_CACHE_STORAGE_PATH="$(E2E_COMPILE_STORAGE)" \
bash scripts/dev-service.sh start "./$(BIN)" "$(E2E_CONFIG)" \
"$(E2E_PID_FILE)" "$(E2E_LOG)" "$(DEV_URL)" --port "$(PORT)"; \
trap - EXIT INT TERM
$(TEST_DOCKER_ALL_TARGETS): test-docker-%: test-e2e-server
@echo "=== [$*] real-client E2E ==="
docker build $(DOCKER_E2E_BUILD_FLAGS) --build-arg DEPSILO_URL=$(DEPSILO_URL) \
-t depsilo-test-$* $(TEST_DIR)/docker-$*
test-docker-docker: test-e2e-server # opt-in:Docker Registry dind E2E
@echo "=== [docker] two clean dind clients via Docker Hub mirror ==="
docker build $(DOCKER_E2E_BUILD_FLAGS) --build-arg DEPSILO_URL=$(DEPSILO_URL) \
-t depsilo-test-docker $(TEST_DIR)/docker-docker
@# Request counters come from access-log rollups flushed in batches
@# (access_log.batch_interval is 5s in config.example.toml), so each check
@# waits for its counter instead of reading it once right after the pull.
@set -eu; \
command -v jq >/dev/null; \
counter() { curl -fsS "$(DEV_URL)/api/v1/stats" | jq -r "$$1"; }; \
await_above() { value=$$(counter "$$1"); for attempt in $$(seq 1 30); do [ "$$value" -gt "$$2" ] && break; sleep 1; value=$$(counter "$$1"); done; echo "$$value"; }; \
explain() { echo "$$1" >&2; grep -E '/v2/|docker' "$(E2E_LOG)" | tail -n 40 >&2 || true; exit 1; }; \
before=$$(counter '.today.total_requests'); \
docker run --rm --privileged --add-host=$(DOCKER_HOST_ALIAS):host-gateway depsilo-test-docker; \
first=$$(await_above '.today.total_requests' "$$before"); \
test "$$first" -gt "$$before" || explain 'Docker Hub pull bypassed Depsilo'; \
sleep 6; \
first_hits=$$(counter '.today.hit_count'); \
docker run --rm --privileged --add-host=$(DOCKER_HOST_ALIAS):host-gateway depsilo-test-docker; \
second_hits=$$(await_above '.today.hit_count' "$$first_hits"); \
test "$$second_hits" -gt "$$first_hits" || explain 'second clean Docker client did not hit Depsilo cache'; \
echo "Docker proxy verified: requests $$before -> $$first, hits $$first_hits -> $$second_hits"
test-e2e: $(TEST_DOCKER_ALL_TARGETS) ## 运行 14 个真实客户端 E2E
@echo ">>> ALL 14 ECOSYSTEMS PASSED"
test-clean: ## 清理端到端测试环境
@bash scripts/e2e-state.sh guard "$(E2E_STATE_DIR)"
@bash scripts/dev-service.sh stop "./$(BIN)" "$(E2E_PID_FILE)"
@bash scripts/e2e-state.sh clean "$(E2E_STATE_DIR)"
@docker rmi $(addprefix depsilo-test-,$(TEST_DOCKER_ECOS)) >/dev/null 2>&1 || true
@echo ">>> test env removed"
# ─── Docker ───────────────────────────────────
DOCKER_IMAGE := depsilo/depsilo
DOCKER_TAG := local
DOCKER_NAME := depsilo-local
COMPOSE ?= docker compose
.PHONY: docker-build docker-run docker-stop docker-logs docker-shell docker-status docker-compose-up docker-compose-down docker-test proxy-env
docker-build: ## 本地构建 Docker 镜像
docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
@echo ">>> built $(DOCKER_IMAGE):$(DOCKER_TAG)"
@docker images $(DOCKER_IMAGE):$(DOCKER_TAG) --format " Size: {{.Size}}"
docker-run: docker-stop ## 运行本地容器
@mkdir -p data
@test -f "$(CONFIG)" || { echo "missing $(CONFIG); create it before docker-run" >&2; exit 1; }
@secret="$${DEPSILO_AUTH_JWT_SECRET:-}"; \
if [ -z "$$secret" ] && [ -s "$(DEV_JWT_SECRET)" ]; then secret=$$(cat "$(DEV_JWT_SECRET)"); fi; \
if [ -z "$$secret" ]; then echo "set DEPSILO_AUTH_JWT_SECRET or run make run once" >&2; exit 1; fi; \
DEPSILO_AUTH_JWT_SECRET="$$secret" docker run -d \
--name $(DOCKER_NAME) \
--user "$$(id -u):$$(id -g)" \
-p $(PORT):23333 \
-v $(CURDIR)/data:/app/data \
-v $(abspath $(CONFIG)):/app/config.toml:ro \
-e HOME=/tmp/depsilo-home \
-e DEPSILO_CONFIG=/app/config.toml \
-e DEPSILO_AUTH_JWT_SECRET \
--restart unless-stopped \
$(DOCKER_IMAGE):$(DOCKER_TAG)
@sleep 2
@if curl -sf http://localhost:$(PORT)/health > /dev/null 2>&1; then \
echo ">>> $(DOCKER_NAME) running http://localhost:$(PORT)"; \
else \
echo ">>> container started, waiting for ready..."; \
sleep 3; \
curl -sf http://localhost:$(PORT)/health > /dev/null 2>&1 && \
echo ">>> $(DOCKER_NAME) running http://localhost:$(PORT)" || \
{ echo ">>> FAILED — docker logs $(DOCKER_NAME)"; docker logs --tail 20 $(DOCKER_NAME); exit 1; }; \
fi
docker-stop: ## 停止并删除本地容器
@docker rm -f $(DOCKER_NAME) 2>/dev/null || true
docker-logs: # 兼容:查看容器日志
docker logs -f $(DOCKER_NAME)
docker-shell: # 兼容:进入容器 shell
docker exec -it $(DOCKER_NAME) sh
docker-status: # 兼容:查看容器状态
@docker ps -f name=$(DOCKER_NAME) --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Size}}"
@echo ""
@curl -sf http://localhost:$(PORT)/health 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "health: unreachable"
docker-compose-up: # 兼容:使用 Docker Compose 启动
PORT=$(PORT) $(COMPOSE) up -d
@echo ">>> running at http://localhost:$(PORT)"
docker-compose-down: # 兼容:停止 Docker Compose
$(COMPOSE) down
docker-test: docker-build docker-run # 兼容:构建、启动并冒烟测试
@echo ""
@echo "=== Smoke Test ==="
@echo -n " health: " && curl -sf http://localhost:$(PORT)/health | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('status','FAIL'))" 2>/dev/null || echo "FAIL"
@echo -n " pypi: " && curl -sf -o /dev/null -w "%{http_code}" http://localhost:$(PORT)/pypi/simple/ && echo "" || echo "FAIL"
@echo -n " npm: " && curl -sf -o /dev/null -w "%{http_code}" http://localhost:$(PORT)/npm/lodash 2>/dev/null && echo "" || echo "FAIL (may need upstream)"
@echo -n " go: " && curl -sf -o /dev/null -w "%{http_code}" http://localhost:$(PORT)/go/github.com/gin-gonic/gin/@v/list 2>/dev/null && echo "" || echo "FAIL (may need upstream)"
@echo -n " maven: " && curl -sf -o /dev/null -w "%{http_code}" http://localhost:$(PORT)/maven/ 2>/dev/null && echo "" || echo "FAIL (may need upstream)"
@echo -n " frontend: " && curl -sf -o /dev/null -w "%{http_code}" http://localhost:$(PORT)/ && echo "" || echo "FAIL"
@echo ""
@echo ">>> Done. Container still running. Use 'make docker-stop' to stop."
proxy-env: cli-activate # 兼容别名:打印代理环境变量
# ─── 发布 ─────────────────────────────────────
.PHONY: release-dry-run release-check
release-dry-run: frontend ## 本地模拟 goreleaser 发布(不推送)
goreleaser release --snapshot --clean --skip=publish
release-check: ## 检查 goreleaser 配置是否正确
goreleaser check
# ─── 清理 ─────────────────────────────────────
.PHONY: clean clean-all lint lint-go lint-web lint-i18n check verify verify-modules verify-build verify-web verify-ui security verify-scripts verify-installer help
clean: stop docker-stop ## 清理构建产物(保留运行数据与开发 JWT)
rm -rf $(BIN_DIR) $(DIST_DIR) $(WEB_DIST) $(DEV_LOG) $(PID_FILE)
@echo ">>> clean done"
clean-all: clean ## 额外删除本地运行数据与开发 JWT(不可恢复)
rm -rf data $(DEV_JWT_SECRET) $(DEV_JWT_SECRET).tmp.*
@echo ">>> local runtime data removed"
lint: lint-go lint-web lint-i18n ## Go/前端/i18n 静态检查
lint-go: prepare-go # 内部:检查 gofmt + go vet
@unformatted="$$(git ls-files --cached --others --exclude-standard -z -- '*.go' | \
xargs -0 sh -c 'for file do [ ! -f "$$file" ] || gofmt -l "$$file"; done' sh)"; \
test -z "$$unformatted" || { echo "gofmt required:"; echo "$$unformatted"; exit 1; }
go vet $(GO_TEST_PKGS)
lint-web: # 内部:检查前端 ESLint
$(NPM_RUN) lint
lint-i18n: ## 检查 zh.ts/en.ts 与 t() 调用是否一致
@python3 scripts/i18n-audit.py
verify-web: # 内部:前端类型、浏览器契约与生产构建
$(NPM_RUN) type-check:e2e
$(NPM_RUN) test:unit
$(NPM_RUN) build
$(NPM_RUN) check:bundle
verify-modules: # 内部:校验 Go 模块完整性与 tidy 状态
go mod verify
go mod tidy -diff
verify-build: prepare-go # 内部:编译正式 CLI 入口
@output="$$(mktemp)"; trap 'rm -f "$$output"' 0; $(GO_BUILD) -o "$$output" ./cmd/depsilo
verify-ui: # 内部:运行完整 Playwright 浏览器测试
$(NPM_RUN) test:ui
security: prepare-go ## 扫描 Go 与全部前端依赖漏洞(需要联网)
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 $(GO_PROD_PKGS)
npm --prefix $(WEB_DIR) audit --audit-level=moderate
verify-scripts: # 内部:安装、开发与发布脚本回归测试
bash -n install.sh scripts/*.sh
bash scripts/test-install-checksum.sh
bash scripts/test-makefile.sh
bash scripts/test-make-dev-run.sh
bash scripts/test-dev-service.sh
bash scripts/test-dev-ui.sh
node scripts/test-vite-proxy-routes.mjs
bash scripts/test-release-tag.sh
bash scripts/test-release-notes.sh
bash scripts/test-release-promotion.sh
bash scripts/test-prepare-v090-compose-upgrade.sh
bash scripts/test-release-workflow.sh
bash scripts/test-benchmark-install.sh
python3 scripts/test-benchmark-cascade.py
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s testground/offline-pypi -p 'test_*.py'
verify-installer: verify-scripts # 兼容别名
check: lint test verify-web verify-build test-ui ## 日常提交前快速检查
verify: lint verify-modules test-full test-race test-integration verify-web verify-build verify-ui verify-scripts ## 完整离线验证
# These aggregate targets share web/dist between Vite and Go's embed package.
# Keep their prerequisites serialized even when a caller passes `make -j`.
.NOTPARALLEL: check verify
# ─── 帮助 ─────────────────────────────────────
help: ## 显示帮助
@grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-21s\033[0m %s\n", $$1, $$2}'