Files
deerflow-code/deploy/linux-docker-offline-deerflow-only/build-release.sh
T

279 lines
12 KiB
Bash

#!/usr/bin/env bash
set -Eeuo pipefail
# 在可联网的 Linux amd64 构建机上执行:
# ./deploy/linux-docker-offline-deerflow-only/build-release.sh WORKSPACE BUILD_ROOT
WORKSPACE="${1:?workspace path is required}"
BUILD_ROOT="${2:?build root is required}"
WORKSPACE="$(realpath "$WORKSPACE")"
BUILD_ROOT="$(realpath -m "$BUILD_ROOT")"
[[ -f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/OFFLINE_INSTALL.sh" ]] || {
echo "invalid DeerFlow workspace: $WORKSPACE" >&2
exit 2
}
[[ -f "$WORKSPACE/offline-backend-20260512/backend/pyproject.toml" ]] || exit 2
[[ -f "$WORKSPACE/frontend-web/package.json" ]] || exit 2
case "$BUILD_ROOT" in
/|/opt|/opt/codex-build|"$WORKSPACE")
echo "unsafe build root: $BUILD_ROOT" >&2
exit 2
;;
esac
STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}"
DEERFLOW_COMMIT="${DEERFLOW_COMMIT_OVERRIDE:-}"
if [[ -z "$DEERFLOW_COMMIT" ]] && git -C "$WORKSPACE" rev-parse HEAD >/dev/null 2>&1; then
DEERFLOW_COMMIT="$(git -C "$WORKSPACE" rev-parse HEAD)"
fi
[[ "$DEERFLOW_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]] || {
echo "set DEERFLOW_COMMIT_OVERRIDE to the 40-character source commit" >&2
exit 2
}
DEERFLOW_SHORT="${DEERFLOW_COMMIT:0:8}"
SOURCE_DIRTY="${DEERFLOW_DIRTY_OVERRIDE:-false}"
[[ "$SOURCE_DIRTY" == "true" || "$SOURCE_DIRTY" == "false" ]] || exit 2
RELEASE_NAME="deerflow-linux-amd64-offline-${STAMP}"
STAGE="$BUILD_ROOT/stage/$RELEASE_NAME"
DIST="$BUILD_ROOT/dist"
CONTEXT_ROOT="$BUILD_ROOT/context/$RELEASE_NAME"
BACKEND_CONTEXT="$CONTEXT_ROOT/backend"
FRONTEND_CONTEXT="$CONTEXT_ROOT/frontend-root"
for target in "$STAGE" "$CONTEXT_ROOT"; do
if [[ -e "$target" ]]; then
resolved="$(realpath "$target")"
case "$resolved" in
"$BUILD_ROOT/stage/$RELEASE_NAME"|"$BUILD_ROOT/context/$RELEASE_NAME") rm -rf -- "$resolved" ;;
*) echo "refusing to replace unexpected path: $resolved" >&2; exit 2 ;;
esac
fi
done
mkdir -p "$STAGE" "$DIST" "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT/frontend-web" \
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx"
echo "[1/7] Assemble sanitized DeerFlow-only release"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/." "$STAGE/"
mkdir -p \
"$STAGE/config/deerflow" \
"$STAGE/config/frontend" \
"$STAGE/agents-skills/deerflow-agents" \
"$STAGE/agents-skills/deerflow-skills" \
"$STAGE/agents-skills/deerflow-knowledge" \
"$STAGE/images" \
"$STAGE/source/wheelhouse" \
"$STAGE/data/generated" \
"$STAGE/data/state"
cp -a "$WORKSPACE/offline-backend-20260512/backend/." "$BACKEND_CONTEXT/"
cp -a "$WORKSPACE/frontend-web/." "$FRONTEND_CONTEXT/frontend-web/"
cp -a "$WORKSPACE/deploy/linux-docker-offline/nginx/." \
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx/"
rm -rf -- \
"$BACKEND_CONTEXT/.venv" \
"$BACKEND_CONTEXT/.deer-flow" \
"$BACKEND_CONTEXT/.pytest_cache" \
"$BACKEND_CONTEXT/.ruff_cache" \
"$FRONTEND_CONTEXT/frontend-web/node_modules" \
"$FRONTEND_CONTEXT/frontend-web/dist" \
"$FRONTEND_CONTEXT/frontend-web/.vite"
find "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT" -type d -name __pycache__ -prune -exec rm -rf -- {} +
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" backend "$BACKEND_CONTEXT"
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" frontend "$FRONTEND_CONTEXT/frontend-web"
# 运行时直接挂载的后端源码。用户后续只需覆盖这三个目录并重启。
mkdir -p "$STAGE/source/backend" "$STAGE/source/frontend/dist"
cp -a "$BACKEND_CONTEXT/app" "$STAGE/source/backend/app"
cp -a "$BACKEND_CONTEXT/packages" "$STAGE/source/backend/packages"
cp -a "$BACKEND_CONTEXT/tests" "$STAGE/source/backend/tests"
cp -a "$BACKEND_CONTEXT/config.yaml" "$STAGE/config/deerflow/config.base.yaml"
cp -a "$BACKEND_CONTEXT/extensions_config.json" "$STAGE/config/deerflow/extensions_config.json"
cp -a "$FRONTEND_CONTEXT/frontend-web/public/runtime-config.js" "$STAGE/config/frontend/runtime-config.base.js"
cp -a "$WORKSPACE/offline-backend-20260512/backend/skills/." "$STAGE/agents-skills/deerflow-skills/"
cp -a "$WORKSPACE/offline-backend-20260512/wheelhouse/." "$STAGE/source/wheelhouse/"
[[ -f "$STAGE/source/wheelhouse/aiomysql-0.3.2-py3-none-any.whl" ]] || {
echo "aiomysql wheel is missing from source/wheelhouse" >&2
exit 2
}
# 预置项目自带的命名智能体;32 位十六进制目录是用户运行数据,不迁移。
AGENT_ROOT="$WORKSPACE/offline-backend-20260512/backend/.deer-flow/agents"
if [[ -d "$AGENT_ROOT" ]]; then
while IFS= read -r -d '' agent_dir; do
agent_name="$(basename "$agent_dir")"
if [[ ! "$agent_name" =~ ^[0-9a-fA-F]{32}$ ]]; then
cp -a "$agent_dir" "$STAGE/agents-skills/deerflow-agents/"
fi
done < <(find "$AGENT_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)
fi
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/docs/." "$STAGE/docs/"
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/README_OFFLINE.md" "$STAGE/README_OFFLINE.md"
printf '%s\n' '把需要预置的本地知识文件放在此目录;本包不导入旧部署业务数据。' \
> "$STAGE/agents-skills/deerflow-knowledge/README.txt"
find "$STAGE" -type d -name __pycache__ -prune -exec rm -rf -- {} +
echo "[2/7] Build current DeerFlow linux/amd64 images"
BACKEND_IMAGE="cmzs/deerflow-backend-offline:${STAMP}-${DEERFLOW_SHORT}"
FRONTEND_IMAGE="cmzs/deerflow-frontend-offline:${STAMP}-${DEERFLOW_SHORT}"
if [[ -n "${DEERFLOW_BACKEND_RUNTIME_BASE:-}" ]]; then
[[ -n "${DEERFLOW_BASE_SOURCE_ROOT:-}" ]] || {
echo "DEERFLOW_BASE_SOURCE_ROOT is required with DEERFLOW_BACKEND_RUNTIME_BASE" >&2
exit 2
}
DEERFLOW_BASE_SOURCE_ROOT="$(realpath "$DEERFLOW_BASE_SOURCE_ROOT")"
for manifest in pyproject.toml uv.lock requirements-offline.txt packages/harness/pyproject.toml; do
cmp -s "$BACKEND_CONTEXT/$manifest" "$DEERFLOW_BASE_SOURCE_ROOT/$manifest" || {
echo "dependency manifest differs from runtime base: $manifest" >&2
exit 2
}
done
docker image inspect "$DEERFLOW_BACKEND_RUNTIME_BASE" >/dev/null
docker build --platform linux/amd64 --network host --provenance=false \
--build-arg "BASE_IMAGE=$DEERFLOW_BACKEND_RUNTIME_BASE" \
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.backend-code-refresh" \
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
else
docker build --platform linux/amd64 --network host --provenance=false \
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.backend" \
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
fi
if [[ -n "${DEERFLOW_FRONTEND_RUNTIME_BASE:-}" ]]; then
docker image inspect "$DEERFLOW_FRONTEND_RUNTIME_BASE" >/dev/null
docker build --platform linux/amd64 --network host --provenance=false \
--build-arg "BASE_IMAGE=$DEERFLOW_FRONTEND_RUNTIME_BASE" \
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.frontend-runtime-config-refresh" \
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
else
docker build --platform linux/amd64 --network host --provenance=false \
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.frontend" \
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
fi
# 从刚构建的前端镜像取出与镜像完全一致的 dist,作为宿主机挂载初始内容。
FRONTEND_EXPORT_CONTAINER="$(docker create "$FRONTEND_IMAGE")"
cleanup_frontend_export() {
docker rm -f "$FRONTEND_EXPORT_CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup_frontend_export EXIT
docker cp "$FRONTEND_EXPORT_CONTAINER:/usr/share/nginx/html/." \
"$STAGE/source/frontend/dist/"
docker rm "$FRONTEND_EXPORT_CONTAINER" >/dev/null
FRONTEND_EXPORT_CONTAINER=""
trap - EXIT
echo "[3/7] Validate image contents and architecture"
docker image inspect "$BACKEND_IMAGE" "$FRONTEND_IMAGE" >/dev/null
[[ "$(docker image inspect -f '{{.Architecture}}' "$BACKEND_IMAGE")" == "amd64" ]] || exit 2
[[ "$(docker image inspect -f '{{.Architecture}}' "$FRONTEND_IMAGE")" == "amd64" ]] || exit 2
docker run --rm --network none "$BACKEND_IMAGE" python -c \
'import app.gateway.app, deerflow; print("backend import smoke passed")'
docker run --rm --network none "$BACKEND_IMAGE" \
/opt/venv/bin/python /app/backend/scripts/office_runtime_selfcheck.py
echo "[4/7] Write version metadata"
cat > "$STAGE/images/deerflow-image-list.txt" <<EOF
$BACKEND_IMAGE
$FRONTEND_IMAGE
EOF
cat > "$STAGE/VERSION.json" <<EOF
{
"release": "$RELEASE_NAME",
"built_at": "$(date --iso-8601=seconds)",
"platform": "linux/amd64",
"deerflow_commit": "$DEERFLOW_COMMIT",
"deerflow_source_includes_worktree_changes": $SOURCE_DIRTY,
"packaging_revision": "compose-v1-office-v1-aiomysql-wheelhouse-multimodel-v1",
"office_runtime_included": true,
"weknora_included": false,
"images": {
"deerflow_backend": "$BACKEND_IMAGE",
"deerflow_frontend": "$FRONTEND_IMAGE"
}
}
EOF
# 固定源码覆盖允许继承的运行时依赖。后续 build-source-update.sh 只要发现
# 任一锁定文件变化就拒绝生成源码包,要求重新制作完整依赖基线。
python3 - "$WORKSPACE" "$STAGE/SOURCE_BASELINE.json" "$RELEASE_NAME" \
"$DEERFLOW_COMMIT" "$BACKEND_IMAGE" "$FRONTEND_IMAGE" <<'PY'
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
workspace = Path(sys.argv[1]).resolve()
output = Path(sys.argv[2])
locked_paths = (
"offline-backend-20260512/backend/pyproject.toml",
"offline-backend-20260512/backend/uv.lock",
"offline-backend-20260512/backend/requirements-offline.txt",
"offline-backend-20260512/backend/packages/harness/pyproject.toml",
"offline-backend-20260512/backend/Dockerfile.backend-offline",
"deploy/linux-docker-offline/Dockerfile.backend",
)
locked: dict[str, str] = {}
for relative in locked_paths:
path = workspace / relative
if not path.is_file():
raise SystemExit(f"runtime dependency lock input is missing: {relative}")
locked[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
data = {
"format_version": 1,
"runtime_base_release": sys.argv[3],
"runtime_base_commit": sys.argv[4],
"backend_image": sys.argv[5],
"frontend_image": sys.argv[6],
"locked_files": locked,
}
output.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
echo "[5/7] Export exactly two offline images"
docker save "$BACKEND_IMAGE" "$FRONTEND_IMAGE" | gzip -1 > \
"$STAGE/images/deerflow-images-linux-amd64.tar.gz"
gzip -t "$STAGE/images/deerflow-images-linux-amd64.tar.gz"
echo "[6/7] Generate checksums and run static release checks"
(
cd "$STAGE"
sha256sum images/deerflow-images-linux-amd64.tar.gz VERSION.json SOURCE_BASELINE.json > SHA256SUMS
)
chmod 0755 "$STAGE/OFFLINE_INSTALL.sh" "$STAGE/UPDATE_INSTALL.sh" \
"$STAGE/scripts/"*.py
find "$STAGE/data" -mindepth 1 -type f -delete
[[ "$(grep -cvE '^[[:space:]]*(#|$)' "$STAGE/images/deerflow-image-list.txt")" == "2" ]] || exit 2
python3 "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/test_release_renderer.py" "$WORKSPACE"
# source/ 是供用户直接覆盖的 DeerFlow 项目源码,可能包含未启用的可选
# WeKnora 适配代码;纯部署校验只禁止实际服务、镜像和部署文件。
if find "$STAGE" -path "$STAGE/source" -prune -o \
\( -iname '*weknora*' -o -iname '*paradedb*' -o -iname '*docreader*' \) \
-print | grep -q .; then
echo "WeKnora-related deployment artifact found in pure DeerFlow stage" >&2
exit 2
fi
if grep -RIlE --exclude-dir=source \
'wechatopenai/weknora|paradedb/paradedb|redis:7\.0-alpine' "$STAGE" | grep -q .; then
echo "WeKnora image/service reference found in pure DeerFlow stage" >&2
exit 2
fi
echo "[7/7] Create final archive"
FINAL="$DIST/$RELEASE_NAME.tar.gz"
tar -C "$(dirname "$STAGE")" -cf - "$(basename "$STAGE")" | gzip -1 > "$FINAL"
gzip -t "$FINAL"
(
cd "$DIST"
sha256sum "$(basename "$FINAL")" > "$(basename "$FINAL").sha256"
)
printf 'FINAL=%s\n' "$FINAL"
printf 'SIZE_BYTES=%s\n' "$(stat -c %s "$FINAL")"
printf 'SHA256=%s\n' "$(sha256sum "$FINAL" | awk '{print $1}')"