name: sep-tests permissions: contents: read on: push: branches: - 'main' - '*.*.*' paths-ignore: - 'docs/**' - '*.md' - '*.mdx' # The only difference between pull_request and pull_request_target is the context in which the workflow runs: # — pull_request_target workflows use the workflow files from the default branch, and secrets are available. # — pull_request workflows use the workflow files from the pull request branch, and secrets are unavailable. # We use pull_request_target so that GITEE_TOKEN (and other repo secrets) are available when building # ragflow:nightly from a fork PR, while still running the full test suite on PRs labeled with `ci`. pull_request: types: [synchronize, labeled] paths-ignore: - 'docs/**' - '*.md' - '*.mdx' schedule: - cron: '0 16 * * *' # This schedule runs every 16:00:00Z(00:00:00+08:00) # https://docs.github.com/en/actions/using-jobs/using-concurrency concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'ci' && format('-ignore-{0}', github.run_id) || '' }} cancel-in-progress: true # infiniflow/onnxruntime_go is a fresh org mirror that is not yet present in the # public Go checksum database / proxy. Mark it private so Go skips the proxy and # sumdb for this path and resolves it directly from GitHub (Go's default # GOPROXY keeps the ",direct" fallback). Required for the in-process DeepDoc # backend to build against github.com/infiniflow/onnxruntime_go. env: GOPRIVATE: github.com/infiniflow/onnxruntime_go jobs: ragflow_preflight: name: ragflow_preflight # https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution # https://github.com/orgs/community/discussions/26261 if: ${{ github.event_name != 'pull_request' && github.event_name != 'pull_request_target' || (github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ci') && (github.event.action != 'labeled' || github.event.label.name == 'ci')) }} permissions: actions: write contents: read runs-on: [ "self-hosted", "ragflow-test" ] outputs: http_api_test_level: ${{ steps.test_level.outputs.http_api_test_level }} has_go_changes: ${{ steps.detect_changes.outputs.has_go_changes }} has_web_changes: ${{ steps.detect_changes.outputs.has_web_changes }} has_native_deepdoc_changes: ${{ steps.detect_changes.outputs.has_native_deepdoc_changes }} steps: - name: Ensure workspace ownership run: | echo "Workflow triggered by ${{ github.event_name }}" echo "chown -R ${USER} ${GITHUB_WORKSPACE}" && sudo chown -R ${USER} ${GITHUB_WORKSPACE} # https://github.com/actions/checkout/issues/1781 - name: Check out code uses: actions/checkout@v6 with: ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} fetch-depth: 0 fetch-tags: true allow-unsafe-pr-checkout: true - name: Check workflow duplication if: ${{ !cancelled() && !failure() }} run: | if [[ ${GITHUB_EVENT_NAME} != "pull_request" && ${GITHUB_EVENT_NAME} != "pull_request_target" && ${GITHUB_EVENT_NAME} != "schedule" ]]; then HEAD=$(git rev-parse HEAD) # Find a PR that introduced a given commit gh auth login --with-token <<< "${{ secrets.GITHUB_TOKEN }}" PR_NUMBER=$(gh pr list --search ${HEAD} --state merged --json number --jq .[0].number) echo "HEAD=${HEAD}" echo "PR_NUMBER=${PR_NUMBER}" if [[ -n "${PR_NUMBER}" ]]; then PR_SHA_FP=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/PR_${PR_NUMBER} if [[ -f "${PR_SHA_FP}" ]]; then read -r PR_SHA PR_RUN_ID < "${PR_SHA_FP}" # Calculate the hash of the current workspace content HEAD_SHA=$(git rev-parse HEAD^{tree}) if [[ "${HEAD_SHA}" == "${PR_SHA}" ]]; then echo "Cancel myself since the workspace content hash is the same with PR #${PR_NUMBER} merged. See ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${PR_RUN_ID} for details." gh run cancel ${GITHUB_RUN_ID} while true; do status=$(gh run view ${GITHUB_RUN_ID} --json status -q .status) [ "${status}" = "completed" ] && break sleep 5 done exit 1 fi fi fi elif [[ ${GITHUB_EVENT_NAME} == "pull_request" || ${GITHUB_EVENT_NAME} == "pull_request_target" ]]; then PR_NUMBER=${{ github.event.pull_request.number }} PR_SHA_FP=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/PR_${PR_NUMBER} # Calculate the hash of the current workspace content PR_SHA=$(git rev-parse HEAD^{tree}) echo "PR #${PR_NUMBER} workspace content hash: ${PR_SHA}" mkdir -p ${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY} echo "${PR_SHA} ${GITHUB_RUN_ID}" > ${PR_SHA_FP} fi ARTIFACTS_DIR=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID} echo "ARTIFACTS_DIR=${ARTIFACTS_DIR}" >> ${GITHUB_ENV} rm -rf ${ARTIFACTS_DIR} && mkdir -p ${ARTIFACTS_DIR} # lefthook's web-checks hook resolves oxlint/oxfmt through # tools/hooks/web_tools.sh, which needs node and npm on PATH. - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "22" - name: Run Lefthook on changed files run: | set -euo pipefail if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then changed_files=$(mktemp) trap 'rm -f "$changed_files"' EXIT git diff --name-only -z ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} \ | while IFS= read -r -d '' file; do if [[ -f "$file" ]]; then printf '%s\0' "$file" fi done > "$changed_files" echo "Changed files to run lefthook on:" if [[ -s "$changed_files" ]]; then while IFS= read -r -d '' file; do printf ' %q\n' "$file" done < "$changed_files" else echo " (none — lefthook will be a no-op)" fi # LEFTHOOK_CHECK_ONLY=1 makes the pre-commit jobs verify without # applying --fix or `git add`, so CI only checks and reports # failures instead of rewriting the working tree. LEFTHOOK_CHECK_ONLY=1 lefthook run pre-commit --files-from-stdin --no-auto-install < "$changed_files" # web-checks is a separate hook because it is not wired into local # commits: it installs a Node toolchain that only CI has a reason to # pay for. It is always check-only, so it needs no CHECK_ONLY flag. lefthook run web-checks --files-from-stdin --no-auto-install < "$changed_files" fi - name: Set test level id: test_level run: | set -euo pipefail if [[ ${GITHUB_EVENT_NAME} == "schedule" ]]; then export HTTP_API_TEST_LEVEL=p3 else export HTTP_API_TEST_LEVEL=p2 fi echo "HTTP_API_TEST_LEVEL=${HTTP_API_TEST_LEVEL}" >> ${GITHUB_ENV} echo "http_api_test_level=${HTTP_API_TEST_LEVEL}" >> ${GITHUB_OUTPUT} - name: Detect changed file types id: detect_changes run: | set -euo pipefail has_go=false has_web=false has_native_deepdoc=false if [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then has_go=true has_web=true has_native_deepdoc=true else if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}) else BASE_SHA="${{ github.event.before }}" if [[ -n "${BASE_SHA}" && ! "${BASE_SHA}" =~ ^0+$ ]]; then CHANGED_FILES=$(git diff --name-only "${BASE_SHA}" "${GITHUB_SHA}") else CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD) fi fi while IFS= read -r file; do case "$file" in # Docker, test, SDK, and workflow changes can alter either server path. # build.sh also controls the C++/native build, so it must trigger native backend. Dockerfile|Dockerfile_ci|docker/**|.github/workflows/**|sdk/**|build.sh) has_go=true has_native_deepdoc=true ;; test/**) has_go=true ;; # DeepDoc native backend (Go inference layer, C++ static lib, pinned testdata). # Must come BEFORE the generic "*.go"/"*.py" rules, otherwise deepdoc/*.go # would be caught by "*.go" first and never set has_native_deepdoc. internal/deepdoc/**|internal/binding/cpp/**|internal/deepdoc/native/testdata.ref) has_go=true has_native_deepdoc=true ;; *.go|go.mod|go.sum) has_go=true ;; web/*) has_web=true ;; esac done <<< "$CHANGED_FILES" if [[ "$has_go" == "false" && "$has_web" == "false" ]]; then has_go=true has_web=true has_native_deepdoc=true fi fi echo "has_go_changes=${has_go}" >> $GITHUB_OUTPUT echo "has_web_changes=${has_web}" >> $GITHUB_OUTPUT echo "has_native_deepdoc_changes=${has_native_deepdoc}" >> $GITHUB_OUTPUT echo "Go: ${has_go}, Web: ${has_web}, Native DeepDoc: ${has_native_deepdoc}" ragflow_tests_infinity: name: ragflow_tests_infinity needs: ragflow_preflight if: ${{ github.event_name != 'pull_request' && github.event_name != 'pull_request_target' || (github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ci') && (github.event.action != 'labeled' || github.event.label.name == 'ci')) }} runs-on: [ "self-hosted", "ragflow-test" ] env: DOC_ENGINE: infinity RAGFLOW_IMAGE: infiniflow/ragflow:${{ github.run_id }}-infinity HTTP_API_TEST_LEVEL: ${{ needs.ragflow_preflight.outputs.http_api_test_level }} HAS_GO: ${{ needs.ragflow_preflight.outputs.has_go_changes }} HAS_WEB: ${{ needs.ragflow_preflight.outputs.has_web_changes }} steps: - name: Ensure workspace ownership if: always() run: | echo "Workflow triggered by ${{ github.event_name }}" echo "chown -R ${USER} ${GITHUB_WORKSPACE}" && sudo chown -R ${USER} ${GITHUB_WORKSPACE} - name: Check out code if: always() uses: actions/checkout@v6 with: ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} fetch-depth: 0 fetch-tags: true allow-unsafe-pr-checkout: true - name: Build ragflow go server run: | set -euo pipefail BUILDER_CONTAINER=ragflow_build_${GITHUB_RUN_ID}_${DOC_ENGINE}_$(od -An -N4 -tx4 /dev/urandom | tr -d ' ') cleanup_builder() { if [[ -n "${BUILDER_CONTAINER:-}" ]]; then sudo docker rm -f -v "${BUILDER_CONTAINER}" >/dev/null 2>&1 || true fi } trap cleanup_builder EXIT TZ=${TZ:-$(readlink -f /etc/localtime | awk -F '/zoneinfo/' '{print $2}')} sudo docker run --privileged -d --name "${BUILDER_CONTAINER}" \ -e TZ="${TZ}" \ -e UV_INDEX=https://mirrors.aliyun.com/pypi/simple \ -v "${PWD}:/ragflow" \ -v "${PWD}/internal/binding/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' ./build.sh --go - name: Run Go unit tests # Runs after `./build.sh --go`, which guarantees the C++ static # library (librag_tokenizer_c_api.a) is present on disk. The Go # test binaries link against it transitively through # `internal/binding`, so running `go test` before the C++ build # fails the link step. # # Excludes packages whose tests fail for environmental reasons # unrelated to the diff: # - internal/tokenizer is split: the pure-Go BPE loader tests # (bpe_loader_test.go) run in the default tier; the C++ binding / # dict-dependent tests (tokenizer_test.go, # tokenizer_concurrent_test.go) and the on-disk anchor test # (bpe_loader_anchors_test.go) are tagged `manual` and need the # docker builder's /usr/share/infinity/resource, so they stay out # of the default run. run: | set -euo pipefail # Provide the cl100k BPE table for the offline tokenizer loader. # The loader reads /ragflow_deps/cl100k_base.tiktoken (or # TIKTOKEN_CACHE_DIR). Try to fetch it directly from the upstream # openai blob first (small ~1.6MB, reachable from the CI runner — the # pre-PR code downloaded it on the fly, so the network path is known # good). If the direct fetch fails (e.g. GFW), fall back to extracting # it from the local infiniflow/ragflow_deps image *if already present* # on the runner (no `docker pull`, since that huge image is first # fetched at the later "Build ragflow:nightly" step). Best-effort: if # neither source works, the loader still fails loudly (no silent # 0-token degradation). # tiktoken is already installed in runner mkdir -p ragflow_deps cp /opt/ragflow_deps/cl100k_base.tiktoken ragflow_deps/cl100k_base.tiktoken || true if [ ! -f ragflow_deps/cl100k_base.tiktoken ]; then mkdir -p ragflow_deps if command -v curl >/dev/null 2>&1; then if curl -fsSL -o ragflow_deps/cl100k_base.tiktoken \ https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken; then echo "tiktoken: cl100k table fetched via curl from openai blob" else echo "tiktoken: curl fetch failed (network/GFW); will try cached image" fi fi if [ ! -f ragflow_deps/cl100k_base.tiktoken ] && docker image inspect infiniflow/ragflow_deps:latest >/dev/null 2>&1; then CID=$(docker create infiniflow/ragflow_deps:latest true) || true if [ -n "${CID:-}" ]; then if docker cp "$CID":/cl100k_base.tiktoken ragflow_deps/cl100k_base.tiktoken 2>/dev/null; then echo "tiktoken: cl100k table copied from cached infiniflow/ragflow_deps image" fi docker rm -f "$CID" >/dev/null 2>&1 || true fi fi fi if [ -f ragflow_deps/cl100k_base.tiktoken ]; then echo "tiktoken: cl100k table provisioned ($(wc -c < ragflow_deps/cl100k_base.tiktoken) bytes)" else echo "tiktoken: cl100k table NOT provisioned — offline loader will fail loudly" fi # Provide the tokenizer assets the four non-cl100k counters load # (ragflow_deps/huggingface.co//). Their only no-network source # is the infiniflow/ragflow_deps image — the runtime images read them from # exactly the same path — so this is the same "if the image is already # cached" fallback the table above uses (again, no `docker pull`). Without # them counter_properties_test / asset_dir_test silently drop their # non-cl100k coverage, which is how a counter regression goes unnoticed. TOKENIZER_ASSET_PATHS=( BAAI/bge-m3/sentencepiece.bpe.model BAAI/bge-large-en-v1.5/vocab.txt Qwen/Qwen3-Embedding-0.6B/tokenizer.json intfloat/e5-mistral-7b-instruct/tokenizer.json ) if docker image inspect infiniflow/ragflow_deps:latest >/dev/null 2>&1; then CID=$(docker create infiniflow/ragflow_deps:latest true) || true if [ -n "${CID:-}" ]; then for asset in "${TOKENIZER_ASSET_PATHS[@]}"; do target="ragflow_deps/huggingface.co/$asset" if [ -f "$target" ]; then continue fi mkdir -p "$(dirname "$target")" if docker cp "$CID:/huggingface.co/$asset" "$target" 2>/dev/null; then echo "tokenizer: $asset copied from cached infiniflow/ragflow_deps image" else echo "tokenizer: $asset absent from the cached image — its counter will skip" fi done docker rm -f "$CID" >/dev/null 2>&1 || true fi else echo "tokenizer: infiniflow/ragflow_deps not cached on this runner — non-cl100k counters will skip" fi PKGS=$(go list ./... 2>/dev/null \ | grep -v '/internal/storage$' \ | grep -v '/internal/handler$' || true) if [ -z "$PKGS" ]; then ./build.sh --test else ./build.sh --test -- $PKGS fi - name: Build ragflow:nightly run: | set -euo pipefail RUNNER_WORKSPACE_PREFIX=${RUNNER_WORKSPACE_PREFIX:-${HOME}} # Build-slot lock. MUST live on HOST-LOCAL storage shared by every runner # container on the SAME host. NOTE: ${RUNNER_WORKSPACE_PREFIX} is a host # bind mount (local), but its 'artifacts/' subdir is NFS — a flock on NFS # can be orphaned when a runner crashes (NLM lease recovery) and then # blocks the other 2 runners indefinitely. We therefore AVOID 'artifacts/' # and place the lock directly under ${RUNNER_WORKSPACE_PREFIX}, which is # still local and still shared by the 3 runners on this host. Override with # BUILD_LOCK_DIR if you mount a different host-local dir into all runners. BUILD_LOCK_DIR=${BUILD_LOCK_DIR:-${RUNNER_WORKSPACE_PREFIX}/ragflow-ci/${GITHUB_REPOSITORY}} if ! mkdir -p "${BUILD_LOCK_DIR}" 2>/dev/null || [ ! -w "${BUILD_LOCK_DIR}" ]; then echo "ERROR: build lock dir '${BUILD_LOCK_DIR}' is missing or not writable by the runner user." >&2 echo " Ensure ${RUNNER_WORKSPACE_PREFIX} (or BUILD_LOCK_DIR) is a writable, host-local dir mounted into every runner (NOT nfs)." >&2 exit 1 fi BUILD_LOCK_FILE=${BUILD_LOCK_DIR}/docker-build.lock # Pass the Gitee personal access token to the Docker build via --build-arg GITEE_TOKEN. # Dockerfile / Dockerfile_ci read it from ARG GITEE_TOKEN (NOT a BuildKit secret), # so the runner must expose GITEE_TOKEN as an environment variable (e.g. via the # runner's docker-compose `environment:` mapping sourced from its .env file). ( flock -w 4200 9 || { echo "Timed out waiting for the shared Docker build slot" >&2; exit 1; } echo "Acquired Docker build slot for ${DOC_ENGINE}" ##sudo docker pull ubuntu:24.04 timeout 1800 sudo DOCKER_BUILDKIT=1 docker build --build-arg NEED_MIRROR=1 --build-arg HTTPS_PROXY=${HTTPS_PROXY} --build-arg HTTP_PROXY=${HTTP_PROXY} --build-arg GITEE_TOKEN=${GITEE_TOKEN} -f Dockerfile -t ${RAGFLOW_IMAGE} . ) 9>"${BUILD_LOCK_FILE}" - name: Prepare Python test environment run: | git config --global url."https://oauth2:${GITEE_TOKEN}@gitee.com/".insteadOf "https://gitee.com/" uv sync --python 3.13 --group test --frozen uv pip install -e sdk/python - name: Prepare function test environment working-directory: docker run: | set -euo pipefail sudo chmod 1777 /tmp ## ss already installed ##sudo apt update && sudo apt install -y iproute2 RUNNER_WORKSPACE_PREFIX=${RUNNER_WORKSPACE_PREFIX:-${HOME}} COMPOSE_PROJECT_NAME="${GITHUB_RUN_ID}-${DOC_ENGINE}" echo "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}" >> ${GITHUB_ENV} echo "RAGFLOW_CONTAINER=${COMPOSE_PROJECT_NAME}-ragflow-cpu-1" >> ${GITHUB_ENV} ARTIFACTS_DIR=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}/${DOC_ENGINE} echo "ARTIFACTS_DIR=${ARTIFACTS_DIR}" >> ${GITHUB_ENV} rm -rf "${ARTIFACTS_DIR}" && mkdir -p "${ARTIFACTS_DIR}" # Determine runner number (default to 1 if not found) RUNNER_NUM=$(sudo docker inspect $(hostname) --format '{{index .Config.Labels "com.docker.compose.container-number"}}' 2>/dev/null || true) if [[ -z "${RUNNER_NUM:-}" ]]; then # GitHub self-hosted runners always expose RUNNER_NAME; hash it to spread candidates. if [[ -n "${RUNNER_NAME:-}" ]]; then RUNNER_NUM=$(( $(printf '%d' 0x$(echo -n "${RUNNER_NAME}" | md5sum | cut -c1-4)) % 1000 )) else RUNNER_NUM=$(( GITHUB_RUN_ID % 1000 )) fi fi # Engine-specific offset partitions keep concurrent engine jobs from # choosing the same host ports when they land on the same self-hosted runner. # A lock plus reservation file closes the check/start race between parallel jobs. PORT_BASES=(1200 1201 23817 23820 5432 5455 9000 9001 6379 6380 6381 6601 9380 9381 9382 9385 80 443 4222 8222 9900 8123) PARTITION_SIZE=3000 # Keep every (base + offset) host port BELOW the Linux ephemeral # range (net.ipv4.ip_local_port_range, default 32768-60999). An # outbound connection from the host can transiently squat on a port # inside that range, and `docker compose up` then fails with # "address already in use" even though the reservation succeeded — # the reservation cannot see it because `docker ps` only lists # container-published ports. Largest base is 23820, so offsets must # stay under 8948 for the ceiling to clear 32768. case "${DOC_ENGINE}" in elasticsearch) PARTITION_BASE=1000 ;; # offsets 1000-3999 -> ports <= 27819 infinity) PARTITION_BASE=4000 ;; # offsets 4000-6999 -> ports <= 30819 *) echo "Unsupported DOC_ENGINE=${DOC_ENGINE}" >&2; exit 1 ;; esac # Runners run inside docker containers that share the host docker daemon # (/var/run/docker.sock), so neither /tmp nor `ss` can coordinate across runners: # each container has its own /tmp and its own network namespace, and `ss` cannot # see host ports bound by other runners' compose stacks. Use the shared daemon for # both primitives: # - per-port mutex = for each host port (base+offset) we intend to bind, create # a docker network named ragflow-ci-port- on the shared daemon. docker # network create is NOT idempotent (fails on name conflict), so only one runner # can claim a given port. This is per-PORT, not per-offset, because different # (base, offset) pairs can produce the same host port (e.g. 6380+5999 == # 9380+2999 == 12379); a per-offset guard cannot prevent that collision. # - port availability = docker ps published ports (sees every container on the # shared daemon regardless of which runner started it). # No file-system sharing required, no host netns required. cleanup_stale_port_locks() { local now stale_after net ts before after now=$(date -u +%s) stale_after=$((60 * 60)) before=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) for net in $(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}'); do ts=$(sudo docker network inspect --format '{{ index .Labels "ragflow-ci-created" }}' "${net}" 2>/dev/null || true) # Remove if timestamp is non-numeric (legacy network from a previous code # version) or if it is numeric and older than stale_after. if [[ ! "${ts}" =~ ^[0-9]+$ ]] || (( now - ts > stale_after )); then sudo docker network rm "${net}" >/dev/null 2>&1 || true fi done after=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) echo " cleanup_stale_port_locks: ${before} -> ${after} guards (TTL=${stale_after}s)" >&2 } reserve_port_offset() { local attempt candidate base port guard g ok reject_reason create_err guard_subnet local -a guards local used guarded need_refresh used_n guarded_n debug_count create_fail_streak cleanup_stale_port_locks echo "Reserving host ports for ${DOC_ENGINE} compose stack..." >&2 # Fail fast if the shared docker daemon is not reachable — avoids a # long spin where every docker network create silently fails. if ! sudo docker info >/dev/null 2>&1; then echo " ERROR: 'sudo docker info' failed — docker daemon is not reachable from this runner" >&2 return 1 fi echo " Running containers on shared daemon: $(sudo docker ps -q 2>/dev/null | wc -l)" >&2 echo " Docker networks on shared daemon: $(sudo docker network ls -q 2>/dev/null | wc -l)" >&2 need_refresh=1 debug_count=0 create_fail_streak=0 for attempt in $(seq 0 499); do candidate=$(( PARTITION_BASE + ((GITHUB_RUN_ID + RUNNER_NUM * 1000 + attempt * 97) % PARTITION_SIZE) )) # Refresh snapshots on first attempt and after a failed reservation. if [[ ${need_refresh} -eq 1 ]]; then used=$(sudo docker ps --format '{{.Ports}}' 2>/dev/null | grep -oE '[0-9]+->' | tr -d '>-' | sort -u || true) # Strip both ragflow-ci-port- (current) and ragflow-ci-portguard- (legacy) prefixes. guarded=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' 2>/dev/null | sed 's/^ragflow-ci-port\(guard\)\?-//' | sort -u || true) used_n=$(echo "${used}" | grep -c . 2>/dev/null || true) guarded_n=$(echo "${guarded}" | grep -c . 2>/dev/null || true) echo " Snapshot: ${used_n} bound ports, ${guarded_n} guarded ports" >&2 [[ ${used_n} -gt 0 ]] && echo " Bound sample: $(echo "${used}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 [[ ${guarded_n} -gt 0 ]] && echo " Guarded sample: $(echo "${guarded}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 need_refresh=0 fi # Fast path: skip if any (base+candidate) port is already bound or guarded. ok=1 reject_reason="" for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) if grep -qx "${port}" <<< "${used}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already bound"; break fi if grep -qx "${port}" <<< "${guarded}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already guarded"; break fi done if [[ ${ok} -eq 0 ]]; then if [[ ${debug_count} -lt 5 ]]; then echo " Candidate ${candidate}: rejected — ${reject_reason}" >&2 debug_count=$((debug_count + 1)) fi continue fi # Atomically claim every (base+candidate) host port via a per-port docker # network. docker network create fails on name conflict, so only one runner # can claim a given port. If any port is already claimed, undo partial # reservations and try the next offset. guards=() ok=1 for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) guard="ragflow-ci-port-${port}" # Use an explicit subnet from 10.0.0.0/8 (outside Docker's # default 172.17-31.x.x pools) to bypass "all predefined # address pools have been fully subnetted" errors on shared # daemons where auto-allocation is exhausted. guard_subnet="10.$((port / 256)).$((port % 256)).0/24" if ! create_err=$(sudo docker network create --subnet "${guard_subnet}" --label ragflow-ci-created="$(date -u +%s)" "${guard}" 2>&1 >/dev/null); then echo " docker network create ${guard} failed: ${create_err}" >&2 ok=0; break fi guards+=("${guard}") done if [[ ${ok} -eq 1 ]]; then PORT_OFFSET=${candidate} PORT_RESERVATION="${guards[*]}" echo "Reserved offset ${PORT_OFFSET} (attempt ${attempt})" >&2 return 0 fi for g in "${guards[@]}"; do sudo docker network rm "${g}" >/dev/null 2>&1 || true done # If docker network create keeps failing (daemon gone, permission, etc.), # bail out early instead of looping 6000 times. A name-conflict failure # should be rare because the fast path already filters guarded ports. create_fail_streak=$((create_fail_streak + 1)) if [[ ${create_fail_streak} -ge 10 ]]; then echo " docker network create failed ${create_fail_streak} consecutive candidates; aborting" >&2 return 1 fi # Another runner just claimed a port; refresh snapshots before retrying. need_refresh=1 done echo " Exhausted ${attempt} candidates. Last snapshot: ${used_n} bound, ${guarded_n} guarded" >&2 return 1 } if ! reserve_port_offset; then echo "Failed to reserve a free host port range for ${DOC_ENGINE} docker compose" >&2 exit 1 fi echo "PORT_RESERVATION=${PORT_RESERVATION}" >> ${GITHUB_ENV} echo "Using ${DOC_ENGINE} host port offset ${PORT_OFFSET}" ES_PORT=$((1200 + PORT_OFFSET)) OS_PORT=$((1201 + PORT_OFFSET)) INFINITY_THRIFT_PORT=$((23817 + PORT_OFFSET)) INFINITY_HTTP_PORT=$((23820 + PORT_OFFSET)) INFINITY_PSQL_PORT=$((5432 + PORT_OFFSET)) EXPOSE_MYSQL_PORT=$((5455 + PORT_OFFSET)) MINIO_PORT=$((9000 + PORT_OFFSET)) MINIO_CONSOLE_PORT=$((9001 + PORT_OFFSET)) REDIS_PORT=$((6379 + PORT_OFFSET)) EXPOSE_NATS_PORT=$((4222 + PORT_OFFSET)) EXPOSE_NATS_MONITORING_PORT=$((8222 + PORT_OFFSET)) TEI_PORT=$((6380 + PORT_OFFSET)) KVROCKS_PORT=$((6381 + PORT_OFFSET)) KIBANA_PORT=$((6601 + PORT_OFFSET)) SVR_HTTP_PORT=$((9380 + PORT_OFFSET)) ADMIN_SVR_HTTP_PORT=$((9381 + PORT_OFFSET)) SVR_MCP_PORT=$((9382 + PORT_OFFSET)) SANDBOX_EXECUTOR_MANAGER_PORT=$((9385 + PORT_OFFSET)) SVR_WEB_HTTP_PORT=$((80 + PORT_OFFSET)) SVR_WEB_HTTPS_PORT=$((443 + PORT_OFFSET)) EXPOSE_CLICKHOUSE_TCP_PORT=$((9900 + PORT_OFFSET)) CLICKHOUSE_HTTP_PORT=$((8123 + PORT_OFFSET)) # Persist computed ports into .env so docker-compose uses the correct host bindings. # Remove previous CI overrides first; docker compose uses the last duplicate key. sed -i '/^ES_PORT=/d;/^OS_PORT=/d;/^INFINITY_THRIFT_PORT=/d;/^INFINITY_HTTP_PORT=/d;/^INFINITY_PSQL_PORT=/d;/^EXPOSE_MYSQL_PORT=/d;/^MINIO_PORT=/d;/^MINIO_CONSOLE_PORT=/d;/^REDIS_PORT=/d;/^KVROCKS_PORT=/d;/^EXPOSE_NATS_PORT=/d;/^EXPOSE_NATS_MONITORING_PORT=/d;/^NATS_MONITORING_PORT=/d;/^TEI_PORT=/d;/^KIBANA_PORT=/d;/^SVR_HTTP_PORT=/d;/^ADMIN_SVR_HTTP_PORT=/d;/^SVR_MCP_PORT=/d;/^SANDBOX_EXECUTOR_MANAGER_PORT=/d;/^SVR_WEB_HTTP_PORT=/d;/^SVR_WEB_HTTPS_PORT=/d;/^EXPOSE_CLICKHOUSE_TCP_PORT=/d;/^CLICKHOUSE_HTTP_PORT=/d;/^COMPOSE_PROFILES=/d;/^TEI_MODEL=/d;/^RAGFLOW_IMAGE=/d;/^DOC_ENGINE=/d' .env # docker-compose-go.yml uses env_file: .env-go for the ragflow-cpu # container, so the Go server process reads COMPOSE_PROFILES and # TEI_MODEL from .env (not .env). Without syncing these two, # the Go server's checkModelAvailable TEI bypass never triggers # because .env's COMPOSE_PROFILES lacks "tei-", causing # PATCH /models/default to fail for the Builtin/Local embedding # model and datasets to be created with an empty embedding_model. sed -i '/^COMPOSE_PROFILES=/d;/^RAGFLOW_DEV_MODE=/d;/^TEI_MODEL=/d' .env { echo "" echo "ES_PORT=${ES_PORT}" echo "OS_PORT=${OS_PORT}" echo "INFINITY_THRIFT_PORT=${INFINITY_THRIFT_PORT}" echo "INFINITY_HTTP_PORT=${INFINITY_HTTP_PORT}" echo "INFINITY_PSQL_PORT=${INFINITY_PSQL_PORT}" echo "EXPOSE_MYSQL_PORT=${EXPOSE_MYSQL_PORT}" echo "MINIO_PORT=${MINIO_PORT}" echo "MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT}" echo "REDIS_PORT=${REDIS_PORT}" echo "KVROCKS_PORT=${KVROCKS_PORT}" echo "EXPOSE_NATS_PORT=${EXPOSE_NATS_PORT}" echo "EXPOSE_NATS_MONITORING_PORT=${EXPOSE_NATS_MONITORING_PORT}" echo "TEI_PORT=${TEI_PORT}" echo "KIBANA_PORT=${KIBANA_PORT}" echo "SVR_HTTP_PORT=${SVR_HTTP_PORT}" echo "ADMIN_SVR_HTTP_PORT=${ADMIN_SVR_HTTP_PORT}" echo "SVR_MCP_PORT=${SVR_MCP_PORT}" echo "SANDBOX_EXECUTOR_MANAGER_PORT=${SANDBOX_EXECUTOR_MANAGER_PORT}" echo "SVR_WEB_HTTP_PORT=${SVR_WEB_HTTP_PORT}" echo "SVR_WEB_HTTPS_PORT=${SVR_WEB_HTTPS_PORT}" echo "EXPOSE_CLICKHOUSE_TCP_PORT=${EXPOSE_CLICKHOUSE_TCP_PORT}" echo "CLICKHOUSE_HTTP_PORT=${CLICKHOUSE_HTTP_PORT}" echo "COMPOSE_PROFILES=${DOC_ENGINE},cpu,ragflow-go,tei-cpu,deepdoc,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" echo "RAGFLOW_IMAGE=${RAGFLOW_IMAGE}" echo "DOC_ENGINE=${DOC_ENGINE}" } >> .env # Sync COMPOSE_PROFILES and TEI_MODEL into .env so the Go # server container (env_file: .env) sees the same TEI bypass # config as the Python container. Only these two affect the # in-process checkModelAvailable logic; other .env keys are # either yml-interpolation-only or already correct in .env. # RAGFLOW_DEV_MODE rides along for the same reason. { echo "" echo "COMPOSE_PROFILES=${DOC_ENGINE},cpu,ragflow-go,tei-cpu,deepdoc,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" # The image stamps its git-describe output into /ragflow/VERSION, # so this checkout reports v0.27.x while --migrate records the # v1.0.0-rc1.dev1 database marker. Without the override the Go # server refuses to boot against the marker it just wrote. echo "RAGFLOW_DEV_MODE=true" } >> .env RAGFLOW_HTTP_PORT=${SVR_WEB_HTTP_PORT} echo "RAGFLOW_HTTP_PORT=${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} echo "HOST_ADDRESS=http://host.docker.internal:${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} - name: Start ragflow:nightly for Infinity run: | set -euo pipefail COMPOSE_FILE=docker/docker-compose.yml sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} up -d - name: Run New RESTFUL api tests against Infinity run: | export http_proxy=""; export https_proxy=""; export no_proxy=""; export HTTP_PROXY=""; export HTTPS_PROXY=""; export NO_PROXY="" svc_ready=0 for i in $(seq 1 60); do if sudo docker exec ${RAGFLOW_CONTAINER} curl -sf --connect-timeout 5 "${HOST_ADDRESS}/api/v1/system/ping" > /dev/null 2>&1; then svc_ready=1 break fi echo "Waiting for service to be available... ($i/60)" sleep 5 done if [ "$svc_ready" -ne 1 ]; then echo "Service did not become ready after 5 minutes. Docker logs:" sudo docker logs ${RAGFLOW_CONTAINER} exit 1 fi source .venv/bin/activate && set -o pipefail; DOC_ENGINE=infinity pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} --durations=100 test/testcases/restful_api 2>&1 | tee infinity_restful_api_test.log - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 if: ${{ !cancelled() }} with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - name: Collect ragflow log Infinity if: ${{ !cancelled() }} # Log collection is diagnostics only: rebuild the artifacts path here # (the directory created by "Prepare function test environment" may be # gone by now, which made `cp` fail with ENOENT) and never fail the job # over a log copy error. run: | set -uo pipefail RUNNER_WORKSPACE_PREFIX="${RUNNER_WORKSPACE_PREFIX:-${HOME}}" ARTIFACTS_DIR="${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}/${DOC_ENGINE}" LOG_DEST="${ARTIFACTS_DIR}/ragflow-logs-infinity" mkdir -p "${ARTIFACTS_DIR}" if [ -d docker/ragflow-logs ]; then sudo rm -rf "${LOG_DEST}" if sudo cp -a docker/ragflow-logs "${LOG_DEST}"; then sudo chown -R "$(id -u):$(id -g)" "${LOG_DEST}" || true echo "Copied ragflow logs to ${LOG_DEST}" else echo "::warning::Failed to copy ragflow logs to ${LOG_DEST}; continuing" fi echo "ragflow log" && sudo tail -n 200 docker/ragflow-logs/ragflow_server.log || true else echo "No docker/ragflow-logs directory found; skipping log collection" fi sudo rm -rf docker/ragflow-logs || true - name: Stop ragflow:nightly for Infinity if: ${{ always() }} run: | set -euo pipefail COMPOSE_FILE=docker/docker-compose.yml # Sometimes `docker compose down` fail due to hang container, heavy load etc. Need to remove such containers to release resources(for example, listen ports). sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f if [[ -n ${RAGFLOW_IMAGE} ]]; then sudo docker rmi -f ${RAGFLOW_IMAGE} fi if [[ -n ${PORT_RESERVATION:-} ]]; then # PORT_RESERVATION is a space-separated list of per-port docker network guards # created by the prepare step. Remove each to free the ports for other runners. for guard in ${PORT_RESERVATION}; do sudo docker network rm "${guard}" >/dev/null 2>&1 || true done fi ragflow_tests_elasticsearch: name: ragflow_tests_elasticsearch needs: ragflow_preflight if: ${{ github.event_name != 'pull_request' && github.event_name != 'pull_request_target' || (github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ci') && (github.event.action != 'labeled' || github.event.label.name == 'ci')) }} runs-on: [ "self-hosted", "ragflow-test" ] env: DOC_ENGINE: elasticsearch RAGFLOW_IMAGE: infiniflow/ragflow:${{ github.run_id }}-elasticsearch HTTP_API_TEST_LEVEL: ${{ needs.ragflow_preflight.outputs.http_api_test_level }} HAS_GO: ${{ needs.ragflow_preflight.outputs.has_go_changes }} HAS_WEB: ${{ needs.ragflow_preflight.outputs.has_web_changes }} steps: - name: Ensure workspace ownership if: always() run: | echo "Workflow triggered by ${{ github.event_name }}" echo "chown -R ${USER} ${GITHUB_WORKSPACE}" && sudo chown -R ${USER} ${GITHUB_WORKSPACE} - name: Check out code if: always() uses: actions/checkout@v6 with: ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} fetch-depth: 0 fetch-tags: true allow-unsafe-pr-checkout: true - name: Build ragflow go server run: | set -euo pipefail BUILDER_CONTAINER=ragflow_build_${GITHUB_RUN_ID}_${DOC_ENGINE}_$(od -An -N4 -tx4 /dev/urandom | tr -d ' ') cleanup_builder() { if [[ -n "${BUILDER_CONTAINER:-}" ]]; then sudo docker rm -f -v "${BUILDER_CONTAINER}" >/dev/null 2>&1 || true fi } trap cleanup_builder EXIT TZ=${TZ:-$(readlink -f /etc/localtime | awk -F '/zoneinfo/' '{print $2}')} sudo docker run --privileged -d --name "${BUILDER_CONTAINER}" \ -e TZ="${TZ}" \ -e UV_INDEX=https://mirrors.aliyun.com/pypi/simple \ -v "${PWD}:/ragflow" \ -v "${PWD}/internal/binding/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' ./build.sh --go - name: Run Go unit tests # Runs after `./build.sh --go`, which guarantees the C++ static # library (librag_tokenizer_c_api.a) is present on disk. The Go # test binaries link against it transitively through # `internal/binding`, so running `go test` before the C++ build # fails the link step. # # Excludes packages whose tests fail for environmental reasons # unrelated to the diff: # - internal/tokenizer is split: the pure-Go BPE loader tests # (bpe_loader_test.go) run in the default tier; the C++ binding / # dict-dependent tests (tokenizer_test.go, # tokenizer_concurrent_test.go) and the on-disk anchor test # (bpe_loader_anchors_test.go) are tagged `manual` and need the # docker builder's /usr/share/infinity/resource, so they stay out # of the default run. run: | set -euo pipefail # Provide the cl100k BPE table for the offline tokenizer loader. # The loader reads /ragflow_deps/cl100k_base.tiktoken (or # TIKTOKEN_CACHE_DIR). Try to fetch it directly from the upstream # openai blob first (small ~1.6MB, reachable from the CI runner — the # pre-PR code downloaded it on the fly, so the network path is known # good). If the direct fetch fails (e.g. GFW), fall back to extracting # it from the local infiniflow/ragflow_deps image *if already present* # on the runner (no `docker pull`, since that huge image is first # fetched at the later "Build ragflow:nightly" step). Best-effort: if # neither source works, the loader still fails loudly (no silent # 0-token degradation). # tiktoken is already installed in runner mkdir -p ragflow_deps cp /opt/ragflow_deps/cl100k_base.tiktoken ragflow_deps/cl100k_base.tiktoken || true if [ ! -f ragflow_deps/cl100k_base.tiktoken ]; then mkdir -p ragflow_deps if command -v curl >/dev/null 2>&1; then if curl -fsSL -o ragflow_deps/cl100k_base.tiktoken \ https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken; then echo "tiktoken: cl100k table fetched via curl from openai blob" else echo "tiktoken: curl fetch failed (network/GFW); will try cached image" fi fi if [ ! -f ragflow_deps/cl100k_base.tiktoken ] && docker image inspect infiniflow/ragflow_deps:latest >/dev/null 2>&1; then CID=$(docker create infiniflow/ragflow_deps:latest true) || true if [ -n "${CID:-}" ]; then if docker cp "$CID":/cl100k_base.tiktoken ragflow_deps/cl100k_base.tiktoken 2>/dev/null; then echo "tiktoken: cl100k table copied from cached infiniflow/ragflow_deps image" fi docker rm -f "$CID" >/dev/null 2>&1 || true fi fi fi if [ -f ragflow_deps/cl100k_base.tiktoken ]; then echo "tiktoken: cl100k table provisioned ($(wc -c < ragflow_deps/cl100k_base.tiktoken) bytes)" else echo "tiktoken: cl100k table NOT provisioned — offline loader will fail loudly" fi # Provide the tokenizer assets the four non-cl100k counters load # (ragflow_deps/huggingface.co//). Their only no-network source # is the infiniflow/ragflow_deps image — the runtime images read them from # exactly the same path — so this is the same "if the image is already # cached" fallback the table above uses (again, no `docker pull`). Without # them counter_properties_test / asset_dir_test silently drop their # non-cl100k coverage, which is how a counter regression goes unnoticed. TOKENIZER_ASSET_PATHS=( BAAI/bge-m3/sentencepiece.bpe.model BAAI/bge-large-en-v1.5/vocab.txt Qwen/Qwen3-Embedding-0.6B/tokenizer.json intfloat/e5-mistral-7b-instruct/tokenizer.json ) if docker image inspect infiniflow/ragflow_deps:latest >/dev/null 2>&1; then CID=$(docker create infiniflow/ragflow_deps:latest true) || true if [ -n "${CID:-}" ]; then for asset in "${TOKENIZER_ASSET_PATHS[@]}"; do target="ragflow_deps/huggingface.co/$asset" if [ -f "$target" ]; then continue fi mkdir -p "$(dirname "$target")" if docker cp "$CID:/huggingface.co/$asset" "$target" 2>/dev/null; then echo "tokenizer: $asset copied from cached infiniflow/ragflow_deps image" else echo "tokenizer: $asset absent from the cached image — its counter will skip" fi done docker rm -f "$CID" >/dev/null 2>&1 || true fi else echo "tokenizer: infiniflow/ragflow_deps not cached on this runner — non-cl100k counters will skip" fi PKGS=$(go list ./... 2>/dev/null \ | grep -v '/internal/storage$' \ | grep -v '/internal/handler$' || true) if [ -z "$PKGS" ]; then ./build.sh --test else ./build.sh --test -- $PKGS fi - name: Run ragflow_deps regression guards # Pure-Python, local, needs no API keys or model downloads; guards (a) the # ONNX Runtime extraction logic the in-process (Go) DeepDoc backend build # depends on (notably the version-bump path C2 hardened) and (b) the # tokenizer-asset chain: the downloader's asset list, the three runtime # Dockerfiles' copy loops and the loaders' SHA-1 pins have to agree, or the # Go counters silently degrade to the calibrated cl100k estimate. Run them # in isolation so the test/testcases conftest (which requires # SILICONFLOW_API_KEY) cannot fail the collection. run: | set -euo pipefail uv sync --python 3.13 --group test --frozen uv run pytest ragflow_deps/test_download_deps.py ragflow_deps/test_tokenizer_assets.py -q --durations=100 - name: Build ragflow:nightly run: | set -euo pipefail RUNNER_WORKSPACE_PREFIX=${RUNNER_WORKSPACE_PREFIX:-${HOME}} # Build-slot lock. MUST live on HOST-LOCAL storage shared by every runner # container on the SAME host. NOTE: ${RUNNER_WORKSPACE_PREFIX} is a host # bind mount (local), but its 'artifacts/' subdir is NFS — a flock on NFS # can be orphaned when a runner crashes (NLM lease recovery) and then # blocks the other 2 runners indefinitely. We therefore AVOID 'artifacts/' # and place the lock directly under ${RUNNER_WORKSPACE_PREFIX}, which is # still local and still shared by the 3 runners on this host. Override with # BUILD_LOCK_DIR if you mount a different host-local dir into all runners. BUILD_LOCK_DIR=${BUILD_LOCK_DIR:-${RUNNER_WORKSPACE_PREFIX}/ragflow-ci/${GITHUB_REPOSITORY}} if ! mkdir -p "${BUILD_LOCK_DIR}" 2>/dev/null || [ ! -w "${BUILD_LOCK_DIR}" ]; then echo "ERROR: build lock dir '${BUILD_LOCK_DIR}' is missing or not writable by the runner user." >&2 echo " Ensure ${RUNNER_WORKSPACE_PREFIX} (or BUILD_LOCK_DIR) is a writable, host-local dir mounted into every runner (NOT nfs)." >&2 exit 1 fi BUILD_LOCK_FILE=${BUILD_LOCK_DIR}/docker-build.lock # Pass the Gitee personal access token to the Docker build via --build-arg GITEE_TOKEN. # Dockerfile / Dockerfile_ci read it from ARG GITEE_TOKEN (NOT a BuildKit secret), # so the runner must expose GITEE_TOKEN as an environment variable (e.g. via the # runner's docker-compose `environment:` mapping sourced from its .env file). ( flock -w 4200 9 || { echo "Timed out waiting for the shared Docker build slot" >&2; exit 1; } echo "Acquired Docker build slot for ${DOC_ENGINE}" ##sudo docker pull ubuntu:24.04 timeout 1800 sudo DOCKER_BUILDKIT=1 docker build --build-arg NEED_MIRROR=1 --build-arg HTTPS_PROXY=${HTTPS_PROXY} --build-arg HTTP_PROXY=${HTTP_PROXY} --build-arg GITEE_TOKEN=${GITEE_TOKEN} -f Dockerfile -t ${RAGFLOW_IMAGE} . ) 9>"${BUILD_LOCK_FILE}" - name: Prepare Python test environment run: | git config --global url."https://oauth2:${GITEE_TOKEN}@gitee.com/".insteadOf "https://gitee.com/" uv sync --python 3.13 --group test --frozen uv pip install -e sdk/python - name: Prepare function test environment working-directory: docker run: | set -euo pipefail sudo chmod 1777 /tmp ## ss already installed ##sudo apt update && sudo apt install -y iproute2 RUNNER_WORKSPACE_PREFIX=${RUNNER_WORKSPACE_PREFIX:-${HOME}} COMPOSE_PROJECT_NAME="${GITHUB_RUN_ID}-${DOC_ENGINE}" echo "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}" >> ${GITHUB_ENV} echo "RAGFLOW_CONTAINER=${COMPOSE_PROJECT_NAME}-ragflow-cpu-1" >> ${GITHUB_ENV} ARTIFACTS_DIR=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}/${DOC_ENGINE} echo "ARTIFACTS_DIR=${ARTIFACTS_DIR}" >> ${GITHUB_ENV} rm -rf "${ARTIFACTS_DIR}" && mkdir -p "${ARTIFACTS_DIR}" # Determine runner number (default to 1 if not found) RUNNER_NUM=$(sudo docker inspect $(hostname) --format '{{index .Config.Labels "com.docker.compose.container-number"}}' 2>/dev/null || true) if [[ -z "${RUNNER_NUM:-}" ]]; then # GitHub self-hosted runners always expose RUNNER_NAME; hash it to spread candidates. if [[ -n "${RUNNER_NAME:-}" ]]; then RUNNER_NUM=$(( $(printf '%d' 0x$(echo -n "${RUNNER_NAME}" | md5sum | cut -c1-4)) % 1000 )) else RUNNER_NUM=$(( GITHUB_RUN_ID % 1000 )) fi fi # Engine-specific offset partitions keep concurrent engine jobs from # choosing the same host ports when they land on the same self-hosted runner. # A lock plus reservation file closes the check/start race between parallel jobs. PORT_BASES=(1200 1201 23817 23820 5432 5455 9000 9001 6379 6380 6381 6601 9380 9381 9382 9385 80 443 4222 8222 9900 8123) PARTITION_SIZE=3000 # Keep every (base + offset) host port BELOW the Linux ephemeral # range (net.ipv4.ip_local_port_range, default 32768-60999). An # outbound connection from the host can transiently squat on a port # inside that range, and `docker compose up` then fails with # "address already in use" even though the reservation succeeded — # the reservation cannot see it because `docker ps` only lists # container-published ports. Largest base is 23820, so offsets must # stay under 8948 for the ceiling to clear 32768. case "${DOC_ENGINE}" in elasticsearch) PARTITION_BASE=1000 ;; # offsets 1000-3999 -> ports <= 27819 infinity) PARTITION_BASE=4000 ;; # offsets 4000-6999 -> ports <= 30819 *) echo "Unsupported DOC_ENGINE=${DOC_ENGINE}" >&2; exit 1 ;; esac # Runners run inside docker containers that share the host docker daemon # (/var/run/docker.sock), so neither /tmp nor `ss` can coordinate across runners: # each container has its own /tmp and its own network namespace, and `ss` cannot # see host ports bound by other runners' compose stacks. Use the shared daemon for # both primitives: # - per-port mutex = for each host port (base+offset) we intend to bind, create # a docker network named ragflow-ci-port- on the shared daemon. docker # network create is NOT idempotent (fails on name conflict), so only one runner # can claim a given port. This is per-PORT, not per-offset, because different # (base, offset) pairs can produce the same host port (e.g. 6380+5999 == # 9380+2999 == 12379); a per-offset guard cannot prevent that collision. # - port availability = docker ps published ports (sees every container on the # shared daemon regardless of which runner started it). # No file-system sharing required, no host netns required. cleanup_stale_port_locks() { local now stale_after net ts before after now=$(date -u +%s) stale_after=$((60 * 60)) before=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) for net in $(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}'); do ts=$(sudo docker network inspect --format '{{ index .Labels "ragflow-ci-created" }}' "${net}" 2>/dev/null || true) # Remove if timestamp is non-numeric (legacy network from a previous code # version) or if it is numeric and older than stale_after. if [[ ! "${ts}" =~ ^[0-9]+$ ]] || (( now - ts > stale_after )); then sudo docker network rm "${net}" >/dev/null 2>&1 || true fi done after=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) echo " cleanup_stale_port_locks: ${before} -> ${after} guards (TTL=${stale_after}s)" >&2 } reserve_port_offset() { local attempt candidate base port guard g ok reject_reason create_err guard_subnet local -a guards local used guarded need_refresh used_n guarded_n debug_count create_fail_streak cleanup_stale_port_locks echo "Reserving host ports for ${DOC_ENGINE} compose stack..." >&2 # Fail fast if the shared docker daemon is not reachable — avoids a # long spin where every docker network create silently fails. if ! sudo docker info >/dev/null 2>&1; then echo " ERROR: 'sudo docker info' failed — docker daemon is not reachable from this runner" >&2 return 1 fi echo " Running containers on shared daemon: $(sudo docker ps -q 2>/dev/null | wc -l)" >&2 echo " Docker networks on shared daemon: $(sudo docker network ls -q 2>/dev/null | wc -l)" >&2 need_refresh=1 debug_count=0 create_fail_streak=0 for attempt in $(seq 0 499); do candidate=$(( PARTITION_BASE + ((GITHUB_RUN_ID + RUNNER_NUM * 1000 + attempt * 97) % PARTITION_SIZE) )) # Refresh snapshots on first attempt and after a failed reservation. if [[ ${need_refresh} -eq 1 ]]; then used=$(sudo docker ps --format '{{.Ports}}' 2>/dev/null | grep -oE '[0-9]+->' | tr -d '>-' | sort -u || true) # Strip both ragflow-ci-port- (current) and ragflow-ci-portguard- (legacy) prefixes. guarded=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' 2>/dev/null | sed 's/^ragflow-ci-port\(guard\)\?-//' | sort -u || true) used_n=$(echo "${used}" | grep -c . 2>/dev/null || true) guarded_n=$(echo "${guarded}" | grep -c . 2>/dev/null || true) echo " Snapshot: ${used_n} bound ports, ${guarded_n} guarded ports" >&2 [[ ${used_n} -gt 0 ]] && echo " Bound sample: $(echo "${used}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 [[ ${guarded_n} -gt 0 ]] && echo " Guarded sample: $(echo "${guarded}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 need_refresh=0 fi # Fast path: skip if any (base+candidate) port is already bound or guarded. ok=1 reject_reason="" for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) if grep -qx "${port}" <<< "${used}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already bound"; break fi if grep -qx "${port}" <<< "${guarded}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already guarded"; break fi done if [[ ${ok} -eq 0 ]]; then if [[ ${debug_count} -lt 5 ]]; then echo " Candidate ${candidate}: rejected — ${reject_reason}" >&2 debug_count=$((debug_count + 1)) fi continue fi # Atomically claim every (base+candidate) host port via a per-port docker # network. docker network create fails on name conflict, so only one runner # can claim a given port. If any port is already claimed, undo partial # reservations and try the next offset. guards=() ok=1 for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) guard="ragflow-ci-port-${port}" # Use an explicit subnet from 10.0.0.0/8 (outside Docker's # default 172.17-31.x.x pools) to bypass "all predefined # address pools have been fully subnetted" errors on shared # daemons where auto-allocation is exhausted. guard_subnet="10.$((port / 256)).$((port % 256)).0/24" if ! create_err=$(sudo docker network create --subnet "${guard_subnet}" --label ragflow-ci-created="$(date -u +%s)" "${guard}" 2>&1 >/dev/null); then echo " docker network create ${guard} failed: ${create_err}" >&2 ok=0; break fi guards+=("${guard}") done if [[ ${ok} -eq 1 ]]; then PORT_OFFSET=${candidate} PORT_RESERVATION="${guards[*]}" echo "Reserved offset ${PORT_OFFSET} (attempt ${attempt})" >&2 return 0 fi for g in "${guards[@]}"; do sudo docker network rm "${g}" >/dev/null 2>&1 || true done # If docker network create keeps failing (daemon gone, permission, etc.), # bail out early instead of looping 6000 times. A name-conflict failure # should be rare because the fast path already filters guarded ports. create_fail_streak=$((create_fail_streak + 1)) if [[ ${create_fail_streak} -ge 10 ]]; then echo " docker network create failed ${create_fail_streak} consecutive candidates; aborting" >&2 return 1 fi # Another runner just claimed a port; refresh snapshots before retrying. need_refresh=1 done echo " Exhausted ${attempt} candidates. Last snapshot: ${used_n} bound, ${guarded_n} guarded" >&2 return 1 } if ! reserve_port_offset; then echo "Failed to reserve a free host port range for ${DOC_ENGINE} docker compose" >&2 exit 1 fi echo "PORT_RESERVATION=${PORT_RESERVATION}" >> ${GITHUB_ENV} echo "Using ${DOC_ENGINE} host port offset ${PORT_OFFSET}" ES_PORT=$((1200 + PORT_OFFSET)) OS_PORT=$((1201 + PORT_OFFSET)) INFINITY_THRIFT_PORT=$((23817 + PORT_OFFSET)) INFINITY_HTTP_PORT=$((23820 + PORT_OFFSET)) INFINITY_PSQL_PORT=$((5432 + PORT_OFFSET)) EXPOSE_MYSQL_PORT=$((5455 + PORT_OFFSET)) MINIO_PORT=$((9000 + PORT_OFFSET)) MINIO_CONSOLE_PORT=$((9001 + PORT_OFFSET)) REDIS_PORT=$((6379 + PORT_OFFSET)) EXPOSE_NATS_PORT=$((4222 + PORT_OFFSET)) EXPOSE_NATS_MONITORING_PORT=$((8222 + PORT_OFFSET)) TEI_PORT=$((6380 + PORT_OFFSET)) KVROCKS_PORT=$((6381 + PORT_OFFSET)) KIBANA_PORT=$((6601 + PORT_OFFSET)) SVR_HTTP_PORT=$((9380 + PORT_OFFSET)) ADMIN_SVR_HTTP_PORT=$((9381 + PORT_OFFSET)) SVR_MCP_PORT=$((9382 + PORT_OFFSET)) SANDBOX_EXECUTOR_MANAGER_PORT=$((9385 + PORT_OFFSET)) SVR_WEB_HTTP_PORT=$((80 + PORT_OFFSET)) SVR_WEB_HTTPS_PORT=$((443 + PORT_OFFSET)) EXPOSE_CLICKHOUSE_TCP_PORT=$((9900 + PORT_OFFSET)) CLICKHOUSE_HTTP_PORT=$((8123 + PORT_OFFSET)) # Persist computed ports into .env so docker-compose uses the correct host bindings. # Remove previous CI overrides first; docker compose uses the last duplicate key. sed -i '/^ES_PORT=/d;/^OS_PORT=/d;/^INFINITY_THRIFT_PORT=/d;/^INFINITY_HTTP_PORT=/d;/^INFINITY_PSQL_PORT=/d;/^EXPOSE_MYSQL_PORT=/d;/^MINIO_PORT=/d;/^MINIO_CONSOLE_PORT=/d;/^REDIS_PORT=/d;/^KVROCKS_PORT=/d;/^EXPOSE_NATS_PORT=/d;/^EXPOSE_NATS_MONITORING_PORT=/d;/^NATS_MONITORING_PORT=/d;/^TEI_PORT=/d;/^KIBANA_PORT=/d;/^SVR_HTTP_PORT=/d;/^ADMIN_SVR_HTTP_PORT=/d;/^SVR_MCP_PORT=/d;/^SANDBOX_EXECUTOR_MANAGER_PORT=/d;/^SVR_WEB_HTTP_PORT=/d;/^SVR_WEB_HTTPS_PORT=/d;/^EXPOSE_CLICKHOUSE_TCP_PORT=/d;/^CLICKHOUSE_HTTP_PORT=/d;/^COMPOSE_PROFILES=/d;/^TEI_MODEL=/d;/^RAGFLOW_IMAGE=/d;/^DOC_ENGINE=/d' .env # docker-compose-go.yml uses env_file: .env-go for the ragflow-cpu # container, so the Go server process reads COMPOSE_PROFILES and # TEI_MODEL from .env (not .env). Without syncing these two, # the Go server's checkModelAvailable TEI bypass never triggers # because .env's COMPOSE_PROFILES lacks "tei-", causing # PATCH /models/default to fail for the Builtin/Local embedding # model and datasets to be created with an empty embedding_model. sed -i '/^COMPOSE_PROFILES=/d;/^RAGFLOW_DEV_MODE=/d;/^TEI_MODEL=/d' .env { echo "" echo "ES_PORT=${ES_PORT}" echo "OS_PORT=${OS_PORT}" echo "INFINITY_THRIFT_PORT=${INFINITY_THRIFT_PORT}" echo "INFINITY_HTTP_PORT=${INFINITY_HTTP_PORT}" echo "INFINITY_PSQL_PORT=${INFINITY_PSQL_PORT}" echo "EXPOSE_MYSQL_PORT=${EXPOSE_MYSQL_PORT}" echo "MINIO_PORT=${MINIO_PORT}" echo "MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT}" echo "REDIS_PORT=${REDIS_PORT}" echo "KVROCKS_PORT=${KVROCKS_PORT}" echo "EXPOSE_NATS_PORT=${EXPOSE_NATS_PORT}" echo "EXPOSE_NATS_MONITORING_PORT=${EXPOSE_NATS_MONITORING_PORT}" echo "TEI_PORT=${TEI_PORT}" echo "KIBANA_PORT=${KIBANA_PORT}" echo "SVR_HTTP_PORT=${SVR_HTTP_PORT}" echo "ADMIN_SVR_HTTP_PORT=${ADMIN_SVR_HTTP_PORT}" echo "SVR_MCP_PORT=${SVR_MCP_PORT}" echo "SANDBOX_EXECUTOR_MANAGER_PORT=${SANDBOX_EXECUTOR_MANAGER_PORT}" echo "SVR_WEB_HTTP_PORT=${SVR_WEB_HTTP_PORT}" echo "SVR_WEB_HTTPS_PORT=${SVR_WEB_HTTPS_PORT}" echo "EXPOSE_CLICKHOUSE_TCP_PORT=${EXPOSE_CLICKHOUSE_TCP_PORT}" echo "CLICKHOUSE_HTTP_PORT=${CLICKHOUSE_HTTP_PORT}" echo "COMPOSE_PROFILES=${DOC_ENGINE},cpu,ragflow-go,tei-cpu,deepdoc,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" echo "RAGFLOW_IMAGE=${RAGFLOW_IMAGE}" echo "DOC_ENGINE=${DOC_ENGINE}" } >> .env # Sync COMPOSE_PROFILES and TEI_MODEL into .env so the Go # server container (env_file: .env) sees the same TEI bypass # config as the Python container. Only these two affect the # in-process checkModelAvailable logic; other .env keys are # either yml-interpolation-only or already correct in .env. # RAGFLOW_DEV_MODE rides along for the same reason. { echo "" echo "COMPOSE_PROFILES=${DOC_ENGINE},cpu,ragflow-go,tei-cpu,deepdoc,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" # The image stamps its git-describe output into /ragflow/VERSION, # so this checkout reports v0.27.x while --migrate records the # v1.0.0-rc1.dev1 database marker. Without the override the Go # server refuses to boot against the marker it just wrote. echo "RAGFLOW_DEV_MODE=true" } >> .env RAGFLOW_HTTP_PORT=${SVR_WEB_HTTP_PORT} echo "RAGFLOW_HTTP_PORT=${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} echo "HOST_ADDRESS=http://host.docker.internal:${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} - name: Start ragflow:nightly for Elasticsearch run: | COMPOSE_FILE=docker/docker-compose.yml sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} up -d - name: Run New RESTFUL api tests against Elasticsearch run: | export http_proxy=""; export https_proxy=""; export no_proxy=""; export HTTP_PROXY=""; export HTTPS_PROXY=""; export NO_PROXY="" svc_ready=0 for i in $(seq 1 60); do if sudo docker exec ${RAGFLOW_CONTAINER} curl -sf --connect-timeout 5 "${HOST_ADDRESS}/api/v1/system/ping" > /dev/null 2>&1; then svc_ready=1 break fi echo "Waiting for service to be available... ($i/60)" sleep 5 done if [ "$svc_ready" -ne 1 ]; then echo "Service did not become ready after 5 minutes. Docker logs:" sudo docker logs ${RAGFLOW_CONTAINER} exit 1 fi source .venv/bin/activate && set -o pipefail; pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} --durations=100 test/testcases/restful_api 2>&1 | tee es_restful_api_test.log - name: Collect ragflow log Elasticsearch if: ${{ !cancelled() }} # Keep this step self-sufficient: rebuild the artifacts path instead of # relying on the directory created earlier, and downgrade copy failures # to warnings so a diagnostics step can never fail the job. run: | set -uo pipefail RUNNER_WORKSPACE_PREFIX="${RUNNER_WORKSPACE_PREFIX:-${HOME}}" ARTIFACTS_DIR="${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}/${DOC_ENGINE}" LOG_DEST="${ARTIFACTS_DIR}/ragflow-logs-es" mkdir -p "${ARTIFACTS_DIR}" if [ -d docker/ragflow-logs ]; then sudo rm -rf "${LOG_DEST}" if sudo cp -a docker/ragflow-logs "${LOG_DEST}"; then sudo chown -R "$(id -u):$(id -g)" "${LOG_DEST}" || true echo "Copied ragflow logs to ${LOG_DEST}" else echo "::warning::Failed to copy ragflow logs to ${LOG_DEST}; continuing" fi echo "ragflow log" && sudo tail -n 200 docker/ragflow-logs/ragflow_server.log || true else echo "No docker/ragflow-logs directory found; skipping log collection" fi sudo rm -rf docker/ragflow-logs || true - name: Stop ragflow:nightly for Elasticsearch if: ${{ always() }} run: | set -euo pipefail COMPOSE_FILE=docker/docker-compose.yml # Sometimes `docker compose down` fail due to hang container, heavy load etc. Need to remove such containers to release resources(for example, listen ports). sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f if [[ -n ${RAGFLOW_IMAGE} ]]; then sudo docker rmi -f ${RAGFLOW_IMAGE} fi if [[ -n ${PORT_RESERVATION:-} ]]; then # PORT_RESERVATION is a space-separated list of per-port docker network guards # created by the prepare step. Remove each to free the ports for other runners. for guard in ${PORT_RESERVATION}; do sudo docker network rm "${guard}" >/dev/null 2>&1 || true done fi ragflow_native_backend: name: ragflow_native_backend # Runs AFTER both per-engine go jobs (which contain the doc-engine-specific # server build + Python RESTFUL correctness runs). The DeepDoc native # backend is doc-engine-independent, so running it ONCE here — instead of # duplicated (and memory-competing) inside each go job — removes the # redundant second -race pass and the concurrent -race OOM risk. Running it # last also keeps a flaky -race OOM from ever obscuring the functional # verdict produced by the go jobs. needs: [ragflow_tests_infinity, ragflow_tests_elasticsearch] # Skip unless DeepDoc native backend code/build/testdata actually changed. if: ${{ (github.event_name != 'pull_request' && github.event_name != 'pull_request_target' || (github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ci') && (github.event.action != 'labeled' || github.event.label.name == 'ci'))) && needs.ragflow_preflight.outputs.has_native_deepdoc_changes == 'true' }} runs-on: [ "self-hosted", "ragflow-test" ] # Shared by the assert step and the test step below so the mount path is # declared once. env: RAGFLOW_TESTDATA_DIR: /opt/ragflow-testdata steps: - name: Ensure workspace ownership run: | echo "Workflow triggered by ${{ github.event_name }}" echo "chown -R ${USER} ${GITHUB_WORKSPACE}" && sudo chown -R ${USER} ${GITHUB_WORKSPACE} - name: Check out code uses: actions/checkout@v6 with: ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} fetch-depth: 0 fetch-tags: true allow-unsafe-pr-checkout: true - name: Build C++ static library (ragflow native backend) # Produces librag_tokenizer_c_api.a with the re2 symbols renamed into # the ragtokre2_ namespace (so it no longer collides with onnxruntime's # bundled re2 copy at link time). Only the .a is needed for the native # integration tests; the per-engine go jobs build the full server. run: | set -euo pipefail BUILDER_CONTAINER=ragflow_build_${GITHUB_RUN_ID}_native_$(od -An -N4 -tx4 /dev/urandom | tr -d ' ') cleanup_builder() { if [[ -n "${BUILDER_CONTAINER:-}" ]]; then sudo docker rm -f -v "${BUILDER_CONTAINER}" >/dev/null 2>&1 || true fi } trap cleanup_builder EXIT TZ=${TZ:-$(readlink -f /etc/localtime | awk -F '/zoneinfo/' '{print $2}')} sudo docker run --privileged -d --name "${BUILDER_CONTAINER}" \ -e TZ="${TZ}" \ -e UV_INDEX=https://mirrors.aliyun.com/pypi/simple \ -v "${PWD}:/ragflow" \ -v "${PWD}/internal/binding/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' - name: Assert pre-seeded DeepDoc testdata # Only the self-hosted ragflow-test runners bake infiniflow/ragflow-testdata # into /opt/ragflow-testdata — the same image-level contract as # MODEL_DIR=/opt/ragflow-deepdoc-models, which has no fallback either. # Fail here rather than letting the fetch script silently fall back to # cloning: a missing mount is an environment defect, and a silent # fallback only makes the job slower while hiding it. # # Assert the `deepdoc` level, not a per-package subtree, so adding a # package later does not invalidate the check. run: | set -euo pipefail if [ ! -d "${RAGFLOW_TESTDATA_DIR}/deepdoc" ]; then echo "ERROR: ${RAGFLOW_TESTDATA_DIR}/deepdoc missing on this runner." >&2 echo " Native backend tests expect the asset repo pre-seeded there." >&2 exit 1 fi echo "✓ pre-seeded testdata found at ${RAGFLOW_TESTDATA_DIR}/deepdoc:" ls -l "${RAGFLOW_TESTDATA_DIR}/deepdoc" - name: Run in-process DeepDoc backend integration tests (correctness + race) # Doc-engine-independent: the same native backend (det/layout/tsr/rec # ONNX models + onnxruntime) is exercised regardless of DOC_ENGINE, so # it is run ONCE here instead of duplicated inside each go job. # # Inside build.sh, the golden/comparison tests run WITHOUT -race (no # race-detection value, ~10x memory tax) and only the concurrency # correctness tests run WITH -race (see run_native_integration_tests). # # Testdata is pre-seeded on the runner at /opt/ragflow-testdata # (asserted by the previous step). RAGFLOW_TESTDATA_DIR points the fetch # script at it so the network clone is skipped (see # scripts/fetch_deepdoc_testdata.sh) — which is why no actions/cache step # is needed here. XDG_CACHE_HOME remains the location the script falls # back to when it runs outside this job (local dev, no mount). env: MODEL_DIR: /opt/ragflow-deepdoc-models CGO_ENABLED: '1' DEEPDOC_NATIVE_REQUIRED: '1' XDG_CACHE_HOME: ${{ github.workspace }}/.cache run: | set -euo pipefail ./build.sh --test-native ragflow_go_integration: name: ragflow_go_integration # Runs the generic Go `integration` test tier against a real service stack # (MySQL/MinIO/Kvrocks/NATS/Infinity/TEI) brought up via docker compose. # The model-backed native DeepDoc tests are covered separately by # `ragflow_native_backend`, so this job stays free of that heavy dependency. needs: ragflow_preflight if: ${{ (github.event_name != 'pull_request' && github.event_name != 'pull_request_target' || (github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ci') && (github.event.action != 'labeled' || github.event.label.name == 'ci'))) && needs.ragflow_preflight.outputs.has_go_changes == 'true' }} runs-on: [ "self-hosted", "ragflow-test" ] env: DOC_ENGINE: infinity steps: - name: Ensure workspace ownership run: | echo "Workflow triggered by ${{ github.event_name }}" echo "chown -R ${USER} ${GITHUB_WORKSPACE}" && sudo chown -R ${USER} ${GITHUB_WORKSPACE} - name: Check out code uses: actions/checkout@v6 with: ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} fetch-depth: 0 fetch-tags: true allow-unsafe-pr-checkout: true - name: Build C++ static library # Produces librag_tokenizer_c_api.a (with re2 symbols renamed into the # ragtokre2_ namespace). Only the .a is needed for the integration test # link; we do NOT build the full ragflow:nightly image because this job # starts only the dependency services, not the ragflow-cpu container. run: | set -euo pipefail BUILDER_CONTAINER=ragflow_build_${GITHUB_RUN_ID}_go_int_$(od -An -N4 -tx4 /dev/urandom | tr -d ' ') cleanup_builder() { if [[ -n "${BUILDER_CONTAINER:-}" ]]; then sudo docker rm -f -v "${BUILDER_CONTAINER}" >/dev/null 2>&1 || true fi } trap cleanup_builder EXIT TZ=${TZ:-$(readlink -f /etc/localtime | awk -F '/zoneinfo/' '{print $2}')} sudo docker run --privileged -d --name "${BUILDER_CONTAINER}" \ -e TZ="${TZ}" \ -e UV_INDEX=https://mirrors.aliyun.com/pypi/simple \ -v "${PWD}:/ragflow" \ -v "${PWD}/internal/binding/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' - name: Prepare function test environment working-directory: docker run: | set -euo pipefail sudo chmod 1777 /tmp ## ss already installed ##sudo apt update && sudo apt install -y iproute2 RUNNER_WORKSPACE_PREFIX=${RUNNER_WORKSPACE_PREFIX:-${HOME}} COMPOSE_PROJECT_NAME="${GITHUB_RUN_ID}-go-int-${DOC_ENGINE}" echo "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}" >> ${GITHUB_ENV} echo "RAGFLOW_CONTAINER=${COMPOSE_PROJECT_NAME}-ragflow-cpu-1" >> ${GITHUB_ENV} ARTIFACTS_DIR=${RUNNER_WORKSPACE_PREFIX}/artifacts/${GITHUB_REPOSITORY}/${GITHUB_RUN_ID}/${DOC_ENGINE} echo "ARTIFACTS_DIR=${ARTIFACTS_DIR}" >> ${GITHUB_ENV} rm -rf "${ARTIFACTS_DIR}" && mkdir -p "${ARTIFACTS_DIR}" # Determine runner number (default to 1 if not found) RUNNER_NUM=$(sudo docker inspect $(hostname) --format '{{index .Config.Labels "com.docker.compose.container-number"}}' 2>/dev/null || true) if [[ -z "${RUNNER_NUM:-}" ]]; then # GitHub self-hosted runners always expose RUNNER_NAME; hash it to spread candidates. if [[ -n "${RUNNER_NAME:-}" ]]; then RUNNER_NUM=$(( $(printf '%d' 0x$(echo -n "${RUNNER_NAME}" | md5sum | cut -c1-4)) % 1000 )) else RUNNER_NUM=$(( GITHUB_RUN_ID % 1000 )) fi fi # Engine-specific offset partitions keep concurrent engine jobs from # choosing the same host ports when they land on the same self-hosted runner. # A lock plus reservation file closes the check/start race between parallel jobs. PORT_BASES=(1200 1201 23817 23820 5432 5455 9000 9001 6379 6380 6381 6601 9380 9381 9382 9385 80 443 4222 8222 9900 8123) PARTITION_SIZE=3000 # Keep every (base + offset) host port BELOW the Linux ephemeral # range (net.ipv4.ip_local_port_range, default 32768-60999). An # outbound connection from the host can transiently squat on a port # inside that range, and `docker compose up` then fails with # "address already in use" even though the reservation succeeded — # the reservation cannot see it because `docker ps` only lists # container-published ports. Largest base is 23820, so offsets must # stay under 8948 for the ceiling to clear 32768. case "${DOC_ENGINE}" in elasticsearch) PARTITION_BASE=1000 ;; # offsets 1000-3999 -> ports <= 27819 infinity) PARTITION_BASE=4000 ;; # offsets 4000-6999 -> ports <= 30819 *) echo "Unsupported DOC_ENGINE=${DOC_ENGINE}" >&2; exit 1 ;; esac # Runners run inside docker containers that share the host docker daemon # (/var/run/docker.sock), so neither /tmp nor `ss` can coordinate across runners: # each container has its own /tmp and its own network namespace, and `ss` cannot # see host ports bound by other runners' compose stacks. Use the shared daemon for # both primitives: # - per-port mutex = for each host port (base+offset) we intend to bind, create # a docker network named ragflow-ci-port- on the shared daemon. docker # network create is NOT idempotent (fails on name conflict), so only one runner # can claim a given port. This is per-PORT, not per-offset, because different # (base, offset) pairs can produce the same host port (e.g. 6380+5999 == # 9380+2999 == 12379); a per-offset guard cannot prevent that collision. # - port availability = docker ps published ports (sees every container on the # shared daemon regardless of which runner started it). # No file-system sharing required, no host netns required. cleanup_stale_port_locks() { local now stale_after net ts before after now=$(date -u +%s) stale_after=$((60 * 60)) before=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) for net in $(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}'); do ts=$(sudo docker network inspect --format '{{ index .Labels "ragflow-ci-created" }}' "${net}" 2>/dev/null || true) # Remove if timestamp is non-numeric (legacy network from a previous code # version) or if it is numeric and older than stale_after. if [[ ! "${ts}" =~ ^[0-9]+$ ]] || (( now - ts > stale_after )); then sudo docker network rm "${net}" >/dev/null 2>&1 || true fi done after=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' | wc -l) echo " cleanup_stale_port_locks: ${before} -> ${after} guards (TTL=${stale_after}s)" >&2 } reserve_port_offset() { local attempt candidate base port guard g ok reject_reason create_err guard_subnet local -a guards local used guarded need_refresh used_n guarded_n debug_count create_fail_streak cleanup_stale_port_locks echo "Reserving host ports for ${DOC_ENGINE} compose stack..." >&2 # Fail fast if the shared docker daemon is not reachable — avoids a # long spin where every docker network create silently fails. if ! sudo docker info >/dev/null 2>&1; then echo " ERROR: 'sudo docker info' failed — docker daemon is not reachable from this runner" >&2 return 1 fi echo " Running containers on shared daemon: $(sudo docker ps -q 2>/dev/null | wc -l)" >&2 echo " Docker networks on shared daemon: $(sudo docker network ls -q 2>/dev/null | wc -l)" >&2 need_refresh=1 debug_count=0 create_fail_streak=0 for attempt in $(seq 0 499); do candidate=$(( PARTITION_BASE + ((GITHUB_RUN_ID + RUNNER_NUM * 1000 + attempt * 97) % PARTITION_SIZE) )) # Refresh snapshots on first attempt and after a failed reservation. if [[ ${need_refresh} -eq 1 ]]; then used=$(sudo docker ps --format '{{.Ports}}' 2>/dev/null | grep -oE '[0-9]+->' | tr -d '>-' | sort -u || true) # Strip both ragflow-ci-port- (current) and ragflow-ci-portguard- (legacy) prefixes. guarded=$(sudo docker network ls --filter "label=ragflow-ci-created" --format '{{.Name}}' 2>/dev/null | sed 's/^ragflow-ci-port\(guard\)\?-//' | sort -u || true) used_n=$(echo "${used}" | grep -c . 2>/dev/null || true) guarded_n=$(echo "${guarded}" | grep -c . 2>/dev/null || true) echo " Snapshot: ${used_n} bound ports, ${guarded_n} guarded ports" >&2 [[ ${used_n} -gt 0 ]] && echo " Bound sample: $(echo "${used}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 [[ ${guarded_n} -gt 0 ]] && echo " Guarded sample: $(echo "${guarded}" | tr '\n' ' ' | cut -d' ' -f1-10)" >&2 need_refresh=0 fi # Fast path: skip if any (base+candidate) port is already bound or guarded. ok=1 reject_reason="" for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) if grep -qx "${port}" <<< "${used}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already bound"; break fi if grep -qx "${port}" <<< "${guarded}" 2>/dev/null; then ok=0; reject_reason="port ${port} (base ${base}) already guarded"; break fi done if [[ ${ok} -eq 0 ]]; then if [[ ${debug_count} -lt 5 ]]; then echo " Candidate ${candidate}: rejected — ${reject_reason}" >&2 debug_count=$((debug_count + 1)) fi continue fi # Atomically claim every (base+candidate) host port via a per-port docker # network. docker network create fails on name conflict, so only one runner # can claim a given port. If any port is already claimed, undo partial # reservations and try the next offset. guards=() ok=1 for base in "${PORT_BASES[@]}"; do port=$((base + candidate)) guard="ragflow-ci-port-${port}" # Use an explicit subnet from 10.0.0.0/8 (outside Docker's # default 172.17-31.x.x pools) to bypass "all predefined # address pools have been fully subnetted" errors on shared # daemons where auto-allocation is exhausted. guard_subnet="10.$((port / 256)).$((port % 256)).0/24" if ! create_err=$(sudo docker network create --subnet "${guard_subnet}" --label ragflow-ci-created="$(date -u +%s)" "${guard}" 2>&1 >/dev/null); then echo " docker network create ${guard} failed: ${create_err}" >&2 ok=0; break fi guards+=("${guard}") done if [[ ${ok} -eq 1 ]]; then PORT_OFFSET=${candidate} PORT_RESERVATION="${guards[*]}" echo "Reserved offset ${PORT_OFFSET} (attempt ${attempt})" >&2 return 0 fi for g in "${guards[@]}"; do sudo docker network rm "${g}" >/dev/null 2>&1 || true done # If docker network create keeps failing (daemon gone, permission, etc.), # bail out early instead of looping 6000 times. A name-conflict failure # should be rare because the fast path already filters guarded ports. create_fail_streak=$((create_fail_streak + 1)) if [[ ${create_fail_streak} -ge 10 ]]; then echo " docker network create failed ${create_fail_streak} consecutive candidates; aborting" >&2 return 1 fi # Another runner just claimed a port; refresh snapshots before retrying. need_refresh=1 done echo " Exhausted ${attempt} candidates. Last snapshot: ${used_n} bound, ${guarded_n} guarded" >&2 return 1 } if ! reserve_port_offset; then echo "Failed to reserve a free host port range for ${DOC_ENGINE} docker compose" >&2 exit 1 fi echo "PORT_RESERVATION=${PORT_RESERVATION}" >> ${GITHUB_ENV} echo "Using ${DOC_ENGINE} host port offset ${PORT_OFFSET}" ES_PORT=$((1200 + PORT_OFFSET)) OS_PORT=$((1201 + PORT_OFFSET)) INFINITY_THRIFT_PORT=$((23817 + PORT_OFFSET)) INFINITY_HTTP_PORT=$((23820 + PORT_OFFSET)) INFINITY_PSQL_PORT=$((5432 + PORT_OFFSET)) EXPOSE_MYSQL_PORT=$((5455 + PORT_OFFSET)) MINIO_PORT=$((9000 + PORT_OFFSET)) MINIO_CONSOLE_PORT=$((9001 + PORT_OFFSET)) REDIS_PORT=$((6379 + PORT_OFFSET)) EXPOSE_NATS_PORT=$((4222 + PORT_OFFSET)) EXPOSE_NATS_MONITORING_PORT=$((8222 + PORT_OFFSET)) TEI_PORT=$((6380 + PORT_OFFSET)) KVROCKS_PORT=$((6381 + PORT_OFFSET)) KIBANA_PORT=$((6601 + PORT_OFFSET)) SVR_HTTP_PORT=$((9380 + PORT_OFFSET)) ADMIN_SVR_HTTP_PORT=$((9381 + PORT_OFFSET)) SVR_MCP_PORT=$((9382 + PORT_OFFSET)) SANDBOX_EXECUTOR_MANAGER_PORT=$((9385 + PORT_OFFSET)) SVR_WEB_HTTP_PORT=$((80 + PORT_OFFSET)) SVR_WEB_HTTPS_PORT=$((443 + PORT_OFFSET)) EXPOSE_CLICKHOUSE_TCP_PORT=$((9900 + PORT_OFFSET)) CLICKHOUSE_HTTP_PORT=$((8123 + PORT_OFFSET)) # Persist computed ports into .env so docker-compose uses the correct host bindings. # Remove previous CI overrides first; docker compose uses the last duplicate key. sed -i '/^ES_PORT=/d;/^OS_PORT=/d;/^INFINITY_THRIFT_PORT=/d;/^INFINITY_HTTP_PORT=/d;/^INFINITY_PSQL_PORT=/d;/^EXPOSE_MYSQL_PORT=/d;/^MINIO_PORT=/d;/^MINIO_CONSOLE_PORT=/d;/^REDIS_PORT=/d;/^KVROCKS_PORT=/d;/^EXPOSE_NATS_PORT=/d;/^EXPOSE_NATS_MONITORING_PORT=/d;/^NATS_MONITORING_PORT=/d;/^TEI_PORT=/d;/^KIBANA_PORT=/d;/^SVR_HTTP_PORT=/d;/^ADMIN_SVR_HTTP_PORT=/d;/^SVR_MCP_PORT=/d;/^SANDBOX_EXECUTOR_MANAGER_PORT=/d;/^SVR_WEB_HTTP_PORT=/d;/^SVR_WEB_HTTPS_PORT=/d;/^EXPOSE_CLICKHOUSE_TCP_PORT=/d;/^CLICKHOUSE_HTTP_PORT=/d;/^COMPOSE_PROFILES=/d;/^TEI_MODEL=/d;/^RAGFLOW_IMAGE=/d;/^DOC_ENGINE=/d' .env # docker-compose-go.yml uses env_file: .env-go for the ragflow-cpu # container, so the Go server process reads COMPOSE_PROFILES and # TEI_MODEL from .env (not .env). Without syncing these two, # the Go server's checkModelAvailable TEI bypass never triggers # because .env's COMPOSE_PROFILES lacks "tei-", causing # PATCH /models/default to fail for the Builtin/Local embedding # model and datasets to be created with an empty embedding_model. sed -i '/^COMPOSE_PROFILES=/d;/^TEI_MODEL=/d' .env { echo "" echo "ES_PORT=${ES_PORT}" echo "OS_PORT=${OS_PORT}" echo "INFINITY_THRIFT_PORT=${INFINITY_THRIFT_PORT}" echo "INFINITY_HTTP_PORT=${INFINITY_HTTP_PORT}" echo "INFINITY_PSQL_PORT=${INFINITY_PSQL_PORT}" echo "EXPOSE_MYSQL_PORT=${EXPOSE_MYSQL_PORT}" echo "MINIO_PORT=${MINIO_PORT}" echo "MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT}" echo "REDIS_PORT=${REDIS_PORT}" echo "KVROCKS_PORT=${KVROCKS_PORT}" echo "EXPOSE_NATS_PORT=${EXPOSE_NATS_PORT}" echo "EXPOSE_NATS_MONITORING_PORT=${EXPOSE_NATS_MONITORING_PORT}" echo "TEI_PORT=${TEI_PORT}" echo "KIBANA_PORT=${KIBANA_PORT}" echo "SVR_HTTP_PORT=${SVR_HTTP_PORT}" echo "ADMIN_SVR_HTTP_PORT=${ADMIN_SVR_HTTP_PORT}" echo "SVR_MCP_PORT=${SVR_MCP_PORT}" echo "SANDBOX_EXECUTOR_MANAGER_PORT=${SANDBOX_EXECUTOR_MANAGER_PORT}" echo "SVR_WEB_HTTP_PORT=${SVR_WEB_HTTP_PORT}" echo "SVR_WEB_HTTPS_PORT=${SVR_WEB_HTTPS_PORT}" echo "EXPOSE_CLICKHOUSE_TCP_PORT=${EXPOSE_CLICKHOUSE_TCP_PORT}" echo "CLICKHOUSE_HTTP_PORT=${CLICKHOUSE_HTTP_PORT}" # NOTE: the engine profile (${DOC_ENGINE} -> "infinity") MUST be # first, otherwise the engine service never starts. We drop # `cpu` (the heavy ragflow-cpu server container) and `deepdoc` # because this job runs the Go tests on the runner host against # the dependency services directly — no server container needed. # `tei-cpu` the embedding # service; `metadata-mysql` the mysql metadata store. echo "COMPOSE_PROFILES=infinity,tei-cpu,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" echo "DOC_ENGINE=${DOC_ENGINE}" } >> .env # Sync COMPOSE_PROFILES and TEI_MODEL into .env so the Go # server container (env_file: .env) sees the same TEI bypass # config as the Python container. Only these two affect the # in-process checkModelAvailable logic; other .env keys are # either yml-interpolation-only or already correct in .env. { echo "" echo "COMPOSE_PROFILES=infinity,tei-cpu,metadata-mysql" echo "TEI_MODEL=BAAI/bge-small-en-v1.5" } >> .env RAGFLOW_HTTP_PORT=${SVR_WEB_HTTP_PORT} echo "RAGFLOW_HTTP_PORT=${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} echo "HOST_ADDRESS=http://host.docker.internal:${RAGFLOW_HTTP_PORT}" >> ${GITHUB_ENV} - name: Start ragflow CI service stack (infinity) # COMPOSE_PROFILES (set in .env by the prepare step) drops `cpu`, so the # heavy ragflow-cpu image is NOT started — only the dependency services # (mysql/minio/kvrocks/redis/nats/infinity/tei/clickhouse) come up. run: | set -euo pipefail # docker-compose-base.yml publishes kvrocks as "${KVROCKS_PORT:-6379}:6379", # so a missing KVROCKS_PORT silently binds the UN-offset host port 6379 and # collides with whatever already holds it ("port is already allocated"). # The offset reservation only guards 6379+offset, never bare 6379, so the # guard cannot catch this. Fail loudly here instead of at compose up. grep -q '^KVROCKS_PORT=' docker/.env || { echo "KVROCKS_PORT missing from docker/.env — compose would fall back to host 6379" >&2 exit 1 } COMPOSE_FILE=docker/docker-compose.yml sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} up -d - name: Wait for service stack to be ready run: | set -euo pipefail _port() { grep -E "^$1=" docker/.env | head -1 | cut -d= -f2-; } INFINITY_HTTP_PORT=$(_port INFINITY_HTTP_PORT) # Infinity must be reachable from the runner the SAME way the Go tests # reach it: via host.docker.internal (the runner's bridge to the host). # This also proves host.docker.internal resolves and the host-published # port is reachable — the exact path the tests use. Use the same # endpoint the infinity healthcheck uses (/admin/node/current) and DO # NOT pass curl -f: infinity's root path returns a non-2xx, which -f # would treat as failure even when the server is up. Any HTTP response # (or a refused connection) decides readiness, matching the compose # healthcheck exactly. INFINITY_READY=0 for i in $(seq 1 60); do if curl -s --connect-timeout 5 "http://host.docker.internal:${INFINITY_HTTP_PORT}/admin/node/current" >/dev/null 2>&1; then echo "Infinity ready after ${i} tries" INFINITY_READY=1 break fi echo "Waiting for Infinity... ($i/60)" sleep 5 done if [ "${INFINITY_READY}" != "1" ]; then echo "::error::Infinity did not become ready on host.docker.internal:${INFINITY_HTTP_PORT}" echo "Last curl attempt (verbose, for diagnosis):" curl -sv --connect-timeout 5 "http://host.docker.internal:${INFINITY_HTTP_PORT}/admin/node/current" || true exit 1 fi # Every other service brought up by the compose profiles (mysql/minio/ # redis/kvrocks/nats/tei/clickhouse) must be healthy before the tests # run. Wait on each container's own healthcheck (defined in # docker-compose-base.yml) instead of re-implementing per-service # probes — this covers all of them without drift and prevents the same # class of "connection refused because the dependency wasn't up yet" # failure we just hit with infinity. Containers without a healthcheck # are treated as immediately healthy. for c in $(sudo docker ps -q --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}"); do name=$(sudo docker inspect -f '{{.Name}}' "$c" | sed 's@^/@@') st="starting" for i in $(seq 1 60); do st=$(sudo docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}healthy{{end}}' "$c") if [ "$st" = "healthy" ]; then break fi sleep 5 done if [ "$st" != "healthy" ]; then echo "::error::${name} did not become healthy (last status: ${st})" sudo docker logs --tail 50 "$c" || true exit 1 fi echo "${name} healthy" done - name: Provide cl100k_base tiktoken vocabulary # The Go integration tests (tokenizer / service / graph) panic without the # cl100k_base BPE table. The runner already bakes it at # /opt/ragflow_deps/cl100k_base.tiktoken, so copy from there first; fall # back to the upstream openai blob only if the local copy is missing # (e.g. a non-standard runner). Mirrors the ragflow_native_backend job. run: | set -euo pipefail mkdir -p ragflow_deps cp /opt/ragflow_deps/cl100k_base.tiktoken ragflow_deps/cl100k_base.tiktoken || true if [ ! -f ragflow_deps/cl100k_base.tiktoken ] && command -v curl >/dev/null 2>&1; then curl -fsSL -o ragflow_deps/cl100k_base.tiktoken \ https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken || \ echo "tiktoken: curl fetch failed; offline loader will fail loudly" fi if [ -f ragflow_deps/cl100k_base.tiktoken ]; then echo "tiktoken: cl100k table provisioned ($(wc -c < ragflow_deps/cl100k_base.tiktoken) bytes)" else echo "tiktoken: cl100k table NOT provisioned — offline loader will fail loudly" fi - name: Run Go integration tests # The Go tests run on the runner host (a container on a bridge network). # The dependency stack is brought up via docker compose on the shared host # daemon, so its ports are published on the *host*, not on the runner's # loopback. We therefore point the tests at host.docker.internal: # (the runner's bridge to the host) — 127.0.0.1 would hit the runner's own # loopback and get "connection refused". This mirrors the HOST_ADDRESS # mechanic already used by the Python/SDK jobs in this file. # Engine-specific suites (ES/OceanBase/SereneDB) self-skip unless their # opt-in env vars are set, so only the infinity + mysql/minio/redis/nats # exercised paths run here. # The ./test/api HTTP e2e suite requires a live ragflow server (ragflow-cpu), # which this job deliberately does NOT start (we drop the `cpu` profile to # avoid spinning up the heavy server image), so it is excluded here. Service- # level integration (storage/engine/ingestion/etc.) runs against the # dependency stack brought up above. env: DOC_ENGINE: infinity CGO_ENABLED: '1' # In-process DeepDoc backend (now the sole inference path) needs the ONNX # models; the self-hosted ragflow-test runner bakes them here. MODEL_DIR: /opt/ragflow-deepdoc-models run: | set -euo pipefail _port() { grep -E "^$1=" docker/.env | head -1 | cut -d= -f2-; } INFINITY_THRIFT_PORT=$(_port INFINITY_THRIFT_PORT) INFINITY_HTTP_PORT=$(_port INFINITY_HTTP_PORT) EXPOSE_MYSQL_PORT=$(_port EXPOSE_MYSQL_PORT) MINIO_PORT=$(_port MINIO_PORT) REDIS_PORT=$(_port REDIS_PORT) EXPOSE_NATS_PORT=$(_port EXPOSE_NATS_PORT) EXPOSE_CLICKHOUSE_TCP_PORT=$(_port EXPOSE_CLICKHOUSE_TCP_PORT) export RAGFLOW_INFINITY_URI="host.docker.internal:${INFINITY_THRIFT_PORT}" export RAGFLOW_MYSQL_HOST=host.docker.internal export RAGFLOW_MYSQL_PORT="${EXPOSE_MYSQL_PORT}" export RAGFLOW_MINIO_HOST="host.docker.internal:${MINIO_PORT}" export RAGFLOW_REDIS_HOST="host.docker.internal:${REDIS_PORT}" export RAGFLOW_NATS_HOST=host.docker.internal export RAGFLOW_NATS_PORT="${EXPOSE_NATS_PORT}" export RAGFLOW_CLICKHOUSE_HOST=host.docker.internal export RAGFLOW_CLICKHOUSE_PORT="${EXPOSE_CLICKHOUSE_TCP_PORT}" # The model-backed native DeepDoc tests under internal/deepdoc/** # register the in-process backend from within their own packages and are # covered separately by the ragflow_native_backend job (--test-native), # so we exclude them here. The chunker cgo PDF test is different: it # registers the in-process backend itself (native_analyzer.Register) # when MODEL_DIR is present, so it runs for real in this job instead of # skipping — the runner bakes MODEL_DIR=/opt/ragflow-deepdoc-models. # The ./test/api HTTP e2e suite needs a live ragflow-cpu server, which # this job does not start. # Like the per-engine go jobs (ragflow_tests_{infinity,elasticsearch}), # we also drop internal/storage: its MinIO-backed integration tests need # a fully-ready MinIO that this CI stack does not reliably provide # (the compose /live probe passes while the S3 API still 502s), so they # are exercised elsewhere, not here. # internal/service/nlp is also excluded: its DatasetNav integration test # compiles only against source fixes (Infinity 3013/3052, nav # available_int, doc_ids_kwd) that are intentionally out of scope for this # CI-enabling PR, so the package is left out of this job's package list # rather than patched. Those fixes land in their own PR. PKGS=$(go list ./... \ | grep -v '/test/api' \ | grep -v '^ragflow/internal/deepdoc' \ | grep -v '/internal/storage$' \ | grep -v '^ragflow/internal/service/nlp$') ./build.sh --test-integration-go $PKGS - name: Tear down if: always() run: | set -euo pipefail COMPOSE_FILE=docker/docker-compose.yml sudo docker compose -f "${COMPOSE_FILE}" -p ${COMPOSE_PROJECT_NAME} down -v || true sudo docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" -q | xargs -r sudo docker rm -f # Release the per-port docker-network guards reserved by the prepare step. for g in ${PORT_RESERVATION}; do sudo docker network rm "$g" >/dev/null 2>&1 || true; done