Files
ragflow/.github/workflows/sep-tests.yml
T

1855 lines
107 KiB
YAML

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 <repo>/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/<repo>/<file>). 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-<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 <repo>/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/<repo>/<file>). 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-<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-<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:<port>
# (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