feat: support gateway-only deployment without client releases
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-10-28 03:44:04 +00:00
parent 267a65305e
commit 295310ab9a
3 changed files with 117 additions and 7 deletions
+41 -7
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env bash
set -Eeuo pipefail
gateway_only=false
if [[ "$#" -eq 1 && "$1" == "--gateway-only" ]]; then
gateway_only=true
elif [[ "$#" -ne 0 ]]; then
echo "用法:./deploy/install.sh [--gateway-only]" >&2
exit 1
fi
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
domain="approval.justconn.top"
public_base_url="https://${domain}"
@@ -18,7 +26,11 @@ npm_cache="${project_root}/.npm-cache"
release_private_key="${project_root}/.secrets/release-signing-private.pem"
release_public_key="${project_root}/distribution/release-signing-public.pem"
for command_name in node npm curl systemctl sudo nginx certbot stat sha256sum tar find sort tail cut date git tea; do
required_commands=(node npm curl systemctl cut git)
if [[ "${gateway_only}" == false ]]; then
required_commands+=(sudo nginx certbot stat sha256sum tar find sort tail date tea)
fi
for command_name in "${required_commands[@]}"; do
command -v "${command_name}" >/dev/null 2>&1 || {
echo "缺少部署命令:${command_name}" >&2
exit 1
@@ -36,9 +48,36 @@ fi
release_commit="$(git rev-parse HEAD)"
remote_main="$(git ls-remote origin refs/heads/main | cut -f1)"
if [[ -z "${remote_main}" || "${remote_main}" != "${release_commit}" ]]; then
echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝创建发布标签。" >&2
echo "当前 main 提交尚未同步到 Gitea origin/main,拒绝部署。" >&2
exit 1
fi
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
exit 1
fi
if [[ "${gateway_only}" == true ]]; then
echo "[1/3] 测试并构建网关"
npm --cache "${npm_cache}" test
node --env-file=.runtime/dingtalk-mcp.env --input-type=module -e '
import { loadConfig } from "./dist/src/config.js";
const config = loadConfig();
if (!config.remoteAuth || !config.xftOpenApi) throw new Error("网关必须同时启用 OAuth、钉钉 OA 与薪福通");
console.log(`网关配置检查通过;每账号会话上限 ${config.mcpSessionMaxPerUser}`);
'
echo "[2/3] 更新并重启网关服务"
node dist/src/admin.js service start --project-root "${project_root}"
echo "[3/3] 验证网关服务与 OAuth 元数据"
systemctl --user is-active --quiet dingtalk-approval-mcp.service
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
--retry 10 --retry-delay 1 --retry-all-errors "${public_base_url}/healthz" >/dev/null
curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
"${public_base_url}/.well-known/oauth-protected-resource/mcp" >/dev/null
echo "网关部署完成:${release_commit};客户端发行物未变更。"
exit 0
fi
release_tag_commit="$(git ls-remote origin "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}" | tail -n1 | cut -f1)"
release_created_at_commit="${release_tag_commit:-${release_commit}}"
if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then
@@ -47,11 +86,6 @@ if ! git cat-file -e "${release_created_at_commit}^{commit}" 2>/dev/null; then
fi
release_created_at="$(git show -s --format=%cI "${release_created_at_commit}")"
if [[ ! -f "${project_root}/.runtime/dingtalk-mcp.env" ]]; then
echo "缺少 .runtime/dingtalk-mcp.env;首次部署前需先配置网关企业凭证。" >&2
exit 1
fi
if [[ ! -f "${release_private_key}" || ! -f "${release_public_key}" ]]; then
echo "缺少 Ed25519 发布密钥;首次部署请先执行 npm run release:keygen 并安全备份私钥。" >&2
exit 1
+6
View File
@@ -81,6 +81,12 @@ cd /path/to/dingMCP
脚本自动测试、构建、用 Ed25519 私钥签署 schema v3 清单、重启服务、原子发布静态文件、安装 Nginx 配置并验证公网健康检查、OAuth 元数据和安装链接。首次签证书时,临时 HTTP 配置只开放 ACME challenge,其余请求返回 403;不会在明文 HTTP 上代理 OAuth、Token 或 MCP。证书通过 Certbot webroot 模式签发,正式配置启用 HTTPS 跳转与不带 `includeSubDomains` 的 HSTS。已有证书会自动复用;签名私钥缺失、权限不是 `0600` 或与固定公钥不匹配时发行会失败。
仅修改服务端、无需发布新客户端时,执行 `./deploy/install.sh --gateway-only`。
该模式同样要求无未提交改动且已同步到 Gitea 的 `main`,运行全量测试和配置检查后重启
网关,再验证公网健康和 OAuth 元数据。它保留服务器凭证、OAuth 数据库、Nginx 配置和
现有客户端发行物,不创建 Release 或标签,也不需要读取发布私钥。服务重启会断开
现有 MCP 连接;客户端需重新建立连接,已有 OAuth 登录凭证保留。
发行物先复制到独立 staging 目录,再通过符号链接原子切换;客户端不会看到新清单配旧 artifact 的中间状态。服务器保留当前版本和最近两个旧版本用于排障,较老版本自动清理。`install.md`、`compat-install.md`、清单、签名、安装器、Skill、校验文件和测试包均以 `no-cache` 提供;内容寻址的客户端 tgz 才使用 immutable 缓存。签名清单的 `createdAt` 超过 180 天时兼容客户端拒绝安装,避免旧签名版本冻结攻击。
卸载:
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
test("gateway-only deployment runs checks and restart without publishing clients or touching secrets", () => {
const root = mkdtempSync(join(tmpdir(), "mock-gateway-deploy-"));
try {
for (const dir of ["deploy", ".runtime", "bin", "release"]) mkdirSync(join(root, dir));
copyFileSync("deploy/install.sh", join(root, "deploy/install.sh"));
writeFileSync(join(root, ".runtime/dingtalk-mcp.env"), "mock-config-preserved");
writeFileSync(join(root, "release/manifest.json"), "mock-existing-client-manifest");
const calls = join(root, "calls.log");
const stub = `#!/bin/bash
printf '%s\\n' "$(basename "$0") $*" >> "$MOCK_DEPLOY_CALLS"
case "$(basename "$0")" in
git)
case "$1" in
branch) echo "\${MOCK_DEPLOY_BRANCH:-main}" ;;
diff) exit "\${MOCK_DEPLOY_DIRTY:-0}" ;;
rev-parse) echo mock-commit ;;
ls-remote) printf 'mock-commit\\trefs/heads/main\\n' ;;
*) exit 77 ;;
esac ;;
npm) exit "\${MOCK_DEPLOY_TEST_EXIT:-0}" ;;
node)
case "$1" in
-p) echo 1.1.0-rc.20 ;;
--env-file=*) exit "\${MOCK_DEPLOY_CONFIG_EXIT:-0}" ;;
dist/src/admin.js) exit 0 ;;
*) exit 77 ;;
esac ;;
curl|systemctl) exit 0 ;;
*) exit 77 ;;
esac
`;
for (const name of ["git", "node", "npm", "curl", "systemctl", "tea", "sudo"]) {
writeFileSync(join(root, "bin", name), stub, { mode: 0o755 });
}
const run = (overrides: NodeJS.ProcessEnv = {}) => {
writeFileSync(calls, "");
const result = spawnSync("bash", [join(root, "deploy/install.sh"), "--gateway-only"], {
cwd: root, encoding: "utf8",
env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}`, MOCK_DEPLOY_CALLS: calls, ...overrides },
});
return { ...result, calls: readFileSync(calls, "utf8") };
};
const success = run();
assert.equal(success.status, 0, success.stderr);
assert.match(success.calls, /npm .* test\n/);
assert.match(success.calls, /node --env-file=.*loadConfig/s);
assert.match(success.calls, /node dist\/src\/admin.js service start --project-root/);
assert.match(success.calls, /systemctl --user is-active --quiet/);
assert.match(success.calls, /\/healthz/);
assert.match(success.calls, /oauth-protected-resource\/mcp/);
assert.doesNotMatch(success.calls, /runtime:verify|release:build|release:verify|test-bundle|refs\/tags|tea |sudo /);
assert.equal(readFileSync(join(root, ".runtime/dingtalk-mcp.env"), "utf8"), "mock-config-preserved");
assert.equal(readFileSync(join(root, "release/manifest.json"), "utf8"), "mock-existing-client-manifest");
for (const failure of [
{ MOCK_DEPLOY_TEST_EXIT: "1" }, { MOCK_DEPLOY_CONFIG_EXIT: "1" },
{ MOCK_DEPLOY_BRANCH: "feature" }, { MOCK_DEPLOY_DIRTY: "1" },
]) {
const result = run(failure);
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.calls, /node dist\/src\/admin.js service start/);
}
} finally { rmSync(root, { recursive: true, force: true }); }
});