#!/usr/bin/env bash set -euo pipefail # 只做本地发布前预检:验证 package 元数据、workspace 内部依赖版本和 # `cargo package --list` 文件边界。它不访问 registry、不上传 crate,也不把 # 尚未发布的内部依赖伪装成已经可安装的包。 usage() { cat <<'EOF' 用法: ./scripts/check-package-manifests.sh [workspace/Cargo.toml] 检查内容: - 所有 workspace package 都有版本、Rust edition、最低 Rust 版本、license 和 description; - path dependency 只能指向 workspace 内部,并且带明确版本要求; - 每个 package 的 cargo package 文件清单不包含 target、.git、数据库或环境文件。 该检查使用 `cargo package --offline --list --no-verify`,只验证本地 manifest 和打包边界;未显式设置 `CARGO_TARGET_DIR` 时,中间产物会放在 `~/data/tmp`(可用 `AGENT_PACKAGE_TMPDIR` 覆盖)并在退出时清理。依赖其它内部 crate 的完整 package 校验仍需目标 registry 先按依赖顺序发布这些 crate。 EOF } if (($# == 1)) && [[ "$1" == "-h" || "$1" == "--help" ]]; then usage exit 0 fi if (($# > 1)); then usage >&2 exit 2 fi caller_pwd="$(pwd -P)" script_path="${BASH_SOURCE[0]}" case "$script_path" in /*) ;; *) script_path="$caller_pwd/$script_path" ;; esac script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd -P)" script_workspace_root="$(cd -- "$script_dir/.." && pwd -P)" manifest_input="${1:-}" if [[ -z "$manifest_input" ]]; then workspace_manifest="$script_workspace_root/Cargo.toml" elif [[ "$manifest_input" == /* ]]; then workspace_manifest="$manifest_input" elif [[ -f "$caller_pwd/$manifest_input" ]]; then workspace_manifest="$caller_pwd/$manifest_input" else workspace_manifest="$script_workspace_root/$manifest_input" fi manifest_dir="$(cd -- "$(dirname -- "$workspace_manifest")" && pwd -P)" workspace_manifest="$manifest_dir/$(basename -- "$workspace_manifest")" workspace_root="$manifest_dir" # `cargo package --list` 仍可能为 workspace 生成 target/package 中间文件。 # 未显式指定 CARGO_TARGET_DIR 时,把它放到本轮临时目录并在退出时清理, # 避免 manifest 预检污染仓库;调用方显式指定时则保留其生命周期和位置。 package_target_dir_owned=0 if [[ -z "${CARGO_TARGET_DIR:-}" ]]; then package_tmp_parent="${AGENT_PACKAGE_TMPDIR:-${TMPDIR:-${HOME:?HOME 未设置}/data/tmp}}" mkdir -p -- "$package_tmp_parent" package_target_dir="$(mktemp -d "${package_tmp_parent%/}/agent-package-manifests.XXXXXX")" export CARGO_TARGET_DIR="$package_target_dir" package_target_dir_owned=1 fi cleanup_package_target() { if (( package_target_dir_owned == 1 )) && [[ -n "${package_target_dir:-}" ]] && [[ -d "$package_target_dir" ]]; then rm -r -- "$package_target_dir" fi } trap cleanup_package_target EXIT [[ -r "$workspace_manifest" ]] || { echo "workspace manifest 不存在或不可读:$workspace_manifest" >&2 exit 1 } grep -Eq '^[[:space:]]*\[workspace\][[:space:]]*$' "$workspace_manifest" || { echo "Cargo.toml 不是 workspace manifest:$workspace_manifest" >&2 exit 1 } metadata="$(cargo metadata --offline --locked --manifest-path "$workspace_manifest" \ --format-version 1 --no-deps)" # Python 负责解析 Cargo metadata,避免用 grep 解析 TOML/JSON 时误判依赖。 # 使用命令替换而不是 process substitution,确保 Python 校验失败时不会被 # `mapfile` 吞掉退出码,也不会拿着不完整的名称列表继续打包检查。 package_names_text="$(printf '%s\n' "$metadata" | \ WORKSPACE_ROOT="$workspace_root" python3 -c ' import json import os import pathlib import sys root = pathlib.Path(os.environ["WORKSPACE_ROOT"]).resolve() metadata = json.load(sys.stdin) packages = metadata.get("packages", []) workspace_ids = set(metadata.get("workspace_members", [])) workspace_packages = [p for p in packages if p.get("id") in workspace_ids] if not workspace_packages: raise SystemExit("workspace 没有可检查的 package") if len(workspace_packages) != len(workspace_ids): raise SystemExit("workspace_members 与 package metadata 数量不一致") names = {p.get("name") for p in workspace_packages} if len(names) != len(workspace_packages): raise SystemExit("workspace package 名称重复") for package in sorted(workspace_packages, key=lambda item: item["name"]): name = package.get("name", "") if not name.strip(): raise SystemExit("workspace package 缺少名称") manifest = pathlib.Path(package["manifest_path"]).resolve() if root not in manifest.parents or manifest == root / "Cargo.toml": raise SystemExit(f"package manifest 不在 crates/ 下:{manifest}") for field in ("version", "edition", "rust_version", "license", "description"): if not str(package.get(field) or "").strip(): raise SystemExit(f"{name} 缺少 package.{field}") if package["edition"] != "2024": raise SystemExit(f"{name} 使用了非 Rust 2024 edition:{package['edition']}") if package["rust_version"] != "1.96": raise SystemExit(f"{name} 的 rust-version 不是 1.96:{package['rust_version']}") for dependency in package.get("dependencies", []): if dependency.get("source") is not None: continue dependency_name = dependency.get("name", "") dependency_path = dependency.get("path") if dependency_name not in names: raise SystemExit( f"path dependency 指向 workspace 外部:{name} -> {dependency_name}" ) if not dependency_path or root not in pathlib.Path(dependency_path).resolve().parents: raise SystemExit( f"path dependency 路径越过 workspace 边界:{name} -> {dependency_name}" ) requirement = str(dependency.get("req") or "").strip() if requirement in ("", "*", "latest"): raise SystemExit( f"path dependency 缺少明确版本要求:{name} -> {dependency_name}" ) print(name) ')" || exit 1 [[ -n "$package_names_text" ]] || { echo "没有找到 workspace package" >&2 exit 1 } mapfile -t package_names <<<"$package_names_text" for package in "${package_names[@]}"; do package_files="$(cargo package --offline --locked --manifest-path "$workspace_manifest" \ -p "$package" --allow-dirty --no-verify --list 2>/dev/null)" || { echo "cargo package 文件清单失败:$package" >&2 exit 1 } grep -Fxq 'Cargo.toml' <<<"$package_files" || { echo "package 缺少 Cargo.toml:$package" >&2 exit 1 } grep -Eq '(^|/)src/' <<<"$package_files" || { echo "package 缺少 src 文件:$package" >&2 exit 1 } if grep -Eiq '(^|/)(\.git|target|\.env|.*\.db([.-]|$)|.*\.sqlite([.-]|$))' \ <<<"$package_files"; then echo "package 清单包含不应发布的文件:$package" >&2 exit 1 fi echo "package manifest passed: $package" done echo "package manifest check passed: ${#package_names[@]} packages"