#!/usr/bin/env bash set -euo pipefail cleanup_ephemeral_lifecycle_lock() { local project="${1:-}" lock_dir lock_file owner mode lock_owner [[ "${project}" =~ ^context-kit-release-[0-9]+$ ]] || { printf 'release-check: refusing non-release lifecycle lock project: %s\n' "${project}" >&2 return 64 } command -v flock >/dev/null 2>&1 || { printf 'release-check: flock is required for lifecycle lock cleanup\n' >&2 return 1 } command -v stat >/dev/null 2>&1 || { printf 'release-check: stat is required for lifecycle lock cleanup\n' >&2 return 1 } lock_dir="/tmp/context-kit-${project}.lock" lock_file="${lock_dir}/lifecycle" [[ -e "${lock_dir}" || -L "${lock_dir}" ]] || return 0 [[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || { printf 'release-check: refusing unsafe lifecycle lock path: %s\n' "${lock_dir}" >&2 return 1 } if [[ -e "${lock_file}" || -L "${lock_file}" ]]; then [[ -f "${lock_file}" && ! -L "${lock_file}" ]] || { printf 'release-check: refusing unsafe lifecycle lock file: %s\n' "${lock_file}" >&2 return 1 } fi ( exec 9>"${lock_file}" || return 1 if ! flock -n 9; then printf 'release-check: lifecycle lock is still held: %s\n' "${lock_dir}" >&2 return 1 fi [[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || { printf 'release-check: lifecycle lock path changed while acquiring it: %s\n' "${lock_dir}" >&2 return 1 } owner="$(stat -c %u "${lock_dir}")" mode="$(stat -c %a "${lock_dir}")" [[ "${owner}" == "$(id -u)" && "${mode}" == "700" ]] || { printf 'release-check: refusing lifecycle lock with uid %s and mode %s: %s\n' "${owner}" "${mode}" "${lock_dir}" >&2 return 1 } [[ -f "${lock_file}" && ! -L "${lock_file}" ]] || { printf 'release-check: lifecycle lock file changed while acquiring it: %s\n' "${lock_file}" >&2 return 1 } lock_owner="$(stat -c %u "${lock_file}")" [[ "${lock_owner}" == "$(id -u)" ]] || { printf 'release-check: refusing lifecycle lock file owned by uid %s: %s\n' "${lock_owner}" "${lock_file}" >&2 return 1 } rm -f -- "${lock_file}" if ! rmdir -- "${lock_dir}"; then printf 'release-check: lifecycle lock directory contains unexpected entries: %s\n' "${lock_dir}" >&2 return 1 fi ) } if [[ "${1:-}" == "--cleanup-ephemeral-lock" ]]; then [[ "$#" -eq 2 ]] || { printf 'usage: scripts/release-check --cleanup-ephemeral-lock context-kit-release-PID\n' >&2 exit 64 } cleanup_ephemeral_lifecycle_lock "$2" exit fi [[ "$#" -eq 0 ]] || { printf 'usage: scripts/release-check\n' >&2 exit 64 } ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT}" tmp_dir="$(mktemp -d)" pick_port() { python - <<'PY' import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) print(sock.getsockname()[1]) PY } release_id="release-$$" export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}" export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" export CONTEXT_KIT_PROJECT_DIR="${ROOT}" CONTEXT_KIT_SEARXNG_PORT="$(pick_port)" CONTEXT_KIT_WEB_SEARCH_PORT="$(pick_port)" CONTEXT_KIT_DOCS_PORT="$(pick_port)" export CONTEXT_KIT_SEARXNG_PORT CONTEXT_KIT_WEB_SEARCH_PORT CONTEXT_KIT_DOCS_PORT export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources" export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769" local_sources_profile="${tmp_dir}/sources.local.txt" export CONTEXT_KIT_DOCS_SOURCES="config/sources.default.txt ${local_sources_profile}" export CONTEXT_KIT_LOCAL_SOURCE_SMOKE_URL="http://127.0.0.1:${CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT}/release-fixture/llms.txt" export CONTEXT_KIT_WEB_SEARCH_IMAGE="context-kit/web-search-mcp:${release_id}" export CONTEXT_KIT_DOCS_IMAGE="context-kit/docs-mcp:${release_id}" mkdir -p "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/release-fixture" printf '%s\n' \ '# Release Check Local Source' \ '' \ '> Exercises machine-local llms.txt serving and refresh behavior.' \ '' \ '## MCP' \ '' \ '- [Model Context Protocol](https://modelcontextprotocol.io/)' \ > "${CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR}/release-fixture/llms.txt" printf '%s\n' "${CONTEXT_KIT_LOCAL_SOURCE_SMOKE_URL}" > "${local_sources_profile}" cleanup() { local status="$?" lock_status=0 trap - EXIT docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml down -v --remove-orphans >/dev/null 2>&1 || true docker image rm "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" "${CONTEXT_KIT_DOCS_IMAGE}" >/dev/null 2>&1 || true cleanup_ephemeral_lifecycle_lock "${CONTEXT_KIT_COMPOSE_PROJECT}" || lock_status=$? rm -rf "${tmp_dir}" if [[ "${status}" -ne 0 ]]; then exit "${status}" fi exit "${lock_status}" } trap cleanup EXIT check_node() { local file for file in "$@"; do node --check "${file}" done } assert_redaction_check_does_not_disclose_matches() { local fixture="${tmp_dir}/redaction-fixture.txt" local output="${tmp_dir}/redaction-output.txt" local blocked_path="/data/proj""ects/context-kit-private-fixture" printf 'blocked=%s\n' "${blocked_path}" > "${fixture}" if bin/context-kit redaction-check "${fixture}" >"${output}" 2>&1; then printf 'redaction-check test unexpectedly passed\n' >&2 return 1 fi if grep -F "${blocked_path}" "${output}" >/dev/null; then printf 'redaction-check disclosed matched content\n' >&2 return 1 fi } assert_web_search_image() { docker run --rm --entrypoint node \ -e EXPECTED_MAX_BYTES="${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}" \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -e ' const fs = require("node:fs"); const expected = Number(process.env.EXPECTED_MAX_BYTES) || 0; const actual = Number(process.env.MAX_BYTES) || 0; if (process.getuid && process.getuid() === 0) process.exit(1); if (actual !== expected) process.exit(1); const serverPath = "/usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/server.js"; const server = fs.readFileSync(serverPath, "utf8"); if (!server.includes("max_download_bytes: z.number().int().min(1).max(MAX_BYTES).optional()")) process.exit(1); if (!server.includes("provider, signal)")) process.exit(1); if (!server.includes("max_download_bytes,\n signal")) process.exit(1); const bingPath = "/usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js"; const bing = fs.readFileSync(bingPath, "utf8"); if (!bing.includes("Context Kit override for @zhafron/mcp-web-search 1.3.0")) process.exit(1); if (!bing.includes("waitForSelector")) process.exit(1); if (!bing.includes("decodeBingRedirect")) process.exit(1); ' >/dev/null docker run --rm --entrypoint /opt/mcp-proxy/bin/python \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -c ' from pathlib import Path root = Path("/opt/mcp-proxy/lib/python3.11/site-packages") session = (root / "mcp/shared/session.py").read_text() proxy = (root / "mcp_proxy/proxy_server.py").read_text() transport = (root / "mcp/client/streamable_http.py").read_text() assert "Context Kit: forward cancellation" in session assert "downstream client disconnected" in proxy assert "_request_cancel_scopes" in transport ' >/dev/null docker run --rm --entrypoint /usr/bin/test \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}" docker run --rm --entrypoint /usr/bin/test \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -x /opt/mcp-proxy/bin/mcp-proxy docker run --rm --entrypoint /usr/bin/test \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -r /usr/local/lib/context-kit/http-entrypoint.mjs docker run --rm --entrypoint /usr/bin/test \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -r /usr/local/lib/context-kit/mcp-probe.mjs } assert_hostile_requests_rejected() { local status status="$(curl -sS -o /dev/null -w '%{http_code}' \ -H 'Host: attacker.example' \ -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")" [[ "${status}" == 421 ]] || { printf 'hostile Host returned HTTP %s instead of 421\n' "${status}" >&2 return 1 } status="$(curl -sS -o /dev/null -w '%{http_code}' \ -H 'Origin: https://attacker.example' \ -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")" [[ "${status}" == 403 ]] || { printf 'hostile Origin returned HTTP %s instead of 403\n' "${status}" >&2 return 1 } } assert_web_search_backend_supervision() { local container_id before after attempt container_id="$(docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml ps -q web-search-mcp)" before="$(docker inspect -f '{{.RestartCount}}' "${container_id}")" docker exec "${container_id}" sh -eu -c ' for command_path in /proc/[0-9]*/cmdline; do command="$(tr "\000" " " < "${command_path}")" case "${command}" in *node*mcp-web-search*) pid="${command_path#/proc/}" pid="${pid%/cmdline}" kill -KILL "${pid}" exit 0 ;; esac done exit 1 ' for ((attempt=1; attempt <= 60; attempt++)); do after="$(docker inspect -f '{{.RestartCount}}' "${container_id}")" if [[ "${after}" -gt "${before}" ]] && node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp" >/dev/null 2>&1; then return 0 fi sleep 1 done printf 'web-search container did not restart after backend death\n' >&2 return 1 } git diff --check HEAD git show --check --format= HEAD >/dev/null git ls-files --cached --error-unmatch \ docker/web-search/patch-mcp-proxy.py \ docker/web-search/patch-mcp-web-search.mjs \ docker/web-search/overrides/bing.js \ docker/docs/constraints.txt \ scripts/mcp-smoke-client.mjs \ scripts/smoke-web-search.mjs \ scripts/smoke-docs.mjs \ scripts/smoke-repomix.mjs \ scripts/test-compose-upgrade.sh \ scripts/test-lifecycle.sh \ scripts/test-web-search-candidate.sh \ scripts/test-web-search-http.mjs \ scripts/test-web-search-quality.mjs \ scripts/test-web-search-stdio-cancellation.mjs \ docker/web-search/mcp-probe.mjs \ docker/web-search/http-entrypoint.mjs \ scripts/release-check >/dev/null bash -n bin/context-kit bash -n scripts/release-check bash -n scripts/test-compose-upgrade.sh bash -n scripts/test-lifecycle.sh sh -n docker/docs/entrypoint.sh check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/overrides/brave.js docker/web-search/overrides/browser-fetch.js docker/web-search/overrides/registry.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/docs-rebuild.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-docs-candidate.mjs scripts/test-web-search-candidate.mjs scripts/test-web-search-http.mjs scripts/test-web-search-quality.mjs scripts/test-web-search-stdio-cancellation.mjs scripts/fixtures/web/mock-server.mjs python3 -c 'import ast, pathlib; ast.parse(pathlib.Path("docker/web-search/patch-mcp-proxy.py").read_text())' node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));' CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json" cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || { printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2 diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true exit 1 } CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json" cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || { printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2 diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true exit 1 } bin/context-kit install opencode > "${tmp_dir}/opencode.json" bin/context-kit install opencode --absolute > "${tmp_dir}/opencode-absolute.json" bin/context-kit install claude > "${tmp_dir}/claude.json" bin/context-kit install claude --absolute > "${tmp_dir}/claude-absolute.json" node -e 'const fs=require("node:fs"); for (const file of process.argv.slice(1)) JSON.parse(fs.readFileSync(file, "utf8"));' \ "${tmp_dir}/opencode.json" \ "${tmp_dir}/opencode-absolute.json" \ "${tmp_dir}/claude.json" \ "${tmp_dir}/claude-absolute.json" bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json" assert_redaction_check_does_not_disclose_matches bash scripts/test-compose-upgrade.sh bash scripts/test-lifecycle.sh node scripts/test-web-search-http.mjs node scripts/test-web-search-quality.mjs python3 scripts/test-doc-snapshots.py bin/context-kit redaction-check docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null if env -u HOME -u CONTEXT_KIT_DATA_DIR -u CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >"${tmp_dir}/compose-no-home.out" 2>"${tmp_dir}/compose-no-home.err"; then printf 'compose config unexpectedly succeeded without HOME or CONTEXT_KIT_DATA_DIR\n' >&2 exit 1 fi CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null bin/context-kit build assert_web_search_image bin/context-kit start bin/context-kit doctor node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp" assert_hostile_requests_rejected node scripts/smoke-web-search.mjs bin/context-kit web-search node scripts/smoke-docs.mjs bin/context-kit docs node scripts/smoke-repomix.mjs bin/context-kit repomix docker run --rm --entrypoint python "${CONTEXT_KIT_DOCS_IMAGE}" -m unittest discover -s /opt/context-kit/tests -t /opt/context-kit CONTEXT_KIT_DOCS_CANDIDATE_IMAGE="${CONTEXT_KIT_DOCS_IMAGE}" \ CONTEXT_KIT_DOCS_TEST_MODELS="${CONTEXT_KIT_DATA_DIR}/models" \ bash scripts/test-docs-candidate.sh CONTEXT_KIT_WEB_SEARCH_CANDIDATE_IMAGE="${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ bash scripts/test-web-search-candidate.sh assert_web_search_backend_supervision printf 'pass release-check\n'