diff --git a/deploy/install.sh b/deploy/install.sh index cecd999..48b9200 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -34,12 +34,18 @@ if [[ "$(git branch --show-current)" != "main" ]] || ! git diff --quiet || ! git exit 1 fi release_commit="$(git rev-parse HEAD)" -release_created_at="$(git show -s --format=%cI "${release_commit}")" 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 exit 1 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 + echo "现有发布标签指向本地不可验证的提交,拒绝重新生成同版本发布物。" >&2 + exit 1 +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 @@ -74,18 +80,25 @@ npm run test-bundle:build -- \ (cd "${release_dir}" && sha256sum -c SHA256SUMS) npm run release:verify -- --release-dir release -echo "[3/8] 发布或复用 Gitea 国内备用下载源" +echo "[3/8] 发布或复用 Gitea 国内首选下载源" gitea_releases_json="$(mktemp)" trap 'rm -f "${gitea_releases_json}"' EXIT tea releases list --remote origin --limit 100 --output json >"${gitea_releases_json}" -if ! node -e 'const fs=require("fs");const tag=process.argv[1];const rows=JSON.parse(fs.readFileSync(0,"utf8"));process.exit(rows.some((row)=>row.tag_name===tag||row.tag===tag||row.Tag===tag)?0:1)' \ - "${release_tag}" <"${gitea_releases_json}"; then +if node scripts/release-tag-exists.mjs "${release_tag}" <"${gitea_releases_json}"; then + : +else + release_lookup_status=$? + if [[ "${release_lookup_status}" -ne 1 ]]; then + echo "无法可靠判断 Gitea Release 是否存在,拒绝继续部署。" >&2 + exit "${release_lookup_status}" + fi release_flags=() [[ "${release_version}" == *-* ]] && release_flags+=(--prerelease) tea releases create --remote origin --tag "${release_tag}" --target "${release_commit}" \ --title "${release_tag}" --note "Signed DingTalk approval client ${release_version}" "${release_flags[@]}" fi -mapfile -t mirror_assets < <(find "${release_dir}" -mindepth 1 -maxdepth 1 -type f -print | sort) +mapfile -t mirror_assets < <(find "${release_dir}" -mindepth 1 -maxdepth 1 -type f \ + ! -name 'agent-test-install.md' ! -name 'dingmcp-test-bundle.tar.gz' -print | sort) missing_mirror_assets=() mirror_check_file="$(mktemp)" for asset in "${mirror_assets[@]}"; do @@ -106,7 +119,7 @@ for asset in "${mirror_assets[@]}"; do asset_name="$(basename "${asset}")" curl --fail --silent --show-error --location "${gitea_release_base_url}/${asset_name}" -o "${mirror_check_file}" [[ "$(sha256sum "${mirror_check_file}" | cut -d' ' -f1)" == "$(sha256sum "${asset}" | cut -d' ' -f1)" ]] || { - echo "Gitea 备用发布物校验失败:${asset_name}" >&2 + echo "Gitea 首选发布物校验失败:${asset_name}" >&2 exit 1 } done diff --git a/docs/deployment.md b/docs/deployment.md index 4be3571..3aa8081 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -118,7 +118,7 @@ https://approval.justconn.top/downloads/dingmcp/install.md Agent 先校验当前平台引导脚本的固定 SHA-256,再执行该引导。平台引导选择并校验 Node 运行时,同时校验首次安装器和发布公钥;安装器随后验证 `manifest.sig` 和全部 artifact 哈希,将内置运行依赖的客户端以 npm 离线模式安装到稳定用户目录并返回标准 MCP 配置。终端安装过程不会从 npm registry 下载额外代码。Bridge 的稳定绝对路径不会包含 NVM 的具体 Node 版本,配置也不再携带 `--url`;网关地址由受签名保护的安装状态提供。 -正式部署会先把同一批不可变发布文件上传到公开 Gitea Release,并把它写成每个 artifact 的首选下载地址;`approval.justconn.top` 静态目录作为签名清单固定的备用地址。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。两个域名仍可能共享基础设施,如需机器级容灾,可再向 `release:build` 追加一个或多个 `--download-mirror-base-url` 指向独立的国内 OSS/COS。 +正式部署会先把同一批不可变安装文件上传到公开 Gitea Release,并把它写成每个 artifact 的首选下载地址;`approval.justconn.top` 静态目录作为签名清单固定的备用地址。黑盒测试包及其动态提示词只发布到自建静态目录,不参与客户端安装镜像。平台引导、首次安装和后续更新都会在网络失败、超时或 404 时自动回退;任何来源一旦返回内容,Ed25519 签名或 SHA-256 不匹配都会立即终止。同一版本重跑会复用 Release Tag 的提交时间,测试包也使用归一化 tar 元数据,保证部署可安全重入。两个域名仍可能共享基础设施,如需机器级容灾,可再向 `release:build` 追加一个或多个 `--download-mirror-base-url` 指向独立的国内 OSS/COS。 公开 `install.md` 必须保留从平台检查到双来源验收的完整编号步骤,并分别提供 Windows PowerShell 与 Linux/macOS 的独立路径。三平台都必须支持系统没有 Node 的情况:发布流程固定 Node 精确版本及 Windows/Linux/macOS x64/ARM64 的官方 SHA-256;平台引导依次使用阿里云、npmmirror 和 Node 官方精确版本地址,网络失败才换源,哈希不符立即终止。禁止使用 `latest` 镜像别名、修改系统 PATH 或要求管理员权限。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a06998a..dee5cc1 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,7 +6,7 @@ - `npm ci`、`npm test`、`npm audit --omit=dev --audit-level=high` 全部通过。 - `npm run runtime:verify` 确认 Node 官方精确版本源和至少一个国内源同时提供全部六个平台制品,且镜像 `SHASUMS256.txt` 与仓库固定哈希一致。 - `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。 -- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部附件逐文件哈希一致;模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。 +- 国内 Gitea 首选源与自建 HTTPS 备用目录的 `manifest.json`、`manifest.sig` 和全部安装附件逐文件哈希一致;黑盒测试包及动态提示词只要求自建目录可用。连续部署同一 Tag 必须复用既有 Release 和固定 `createdAt`,不得重复创建或因测试包时间戳产生哈希漂移。模拟首选源网络失败/404 后能从备用源完成首次安装和更新,模拟首选源返回错误哈希或签名时必须立即停止而不换源。 - Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。 - 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。 - 在未安装 Node 的干净 Windows x64/ARM64、Linux x64/ARM64、macOS Intel/Apple Silicon 用户环境验证平台引导。确认只使用固定精确版本、国内镜像失败会依次回退、任一哈希不符立即终止、私有 Node 不修改 PATH 且更新后仍可启动。至少在实际可用的每类操作系统完成一次端到端安装;其余架构用对应 CI 或发行制品校验覆盖。 diff --git a/package-lock.json b/package-lock.json index 759634a..c5c62e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.11", + "version": "1.1.0-rc.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.11", + "version": "1.1.0-rc.12", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "express": "^5.2.1", diff --git a/package.json b/package.json index e8068bf..b95fa6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dingtalk-approval-mcp", - "version": "1.1.0-rc.11", + "version": "1.1.0-rc.12", "description": "A lightweight Agent bridge for a unified DingTalk OA and XFT approval gateway.", "private": true, "type": "module", diff --git a/scripts/build-test-bundle.mjs b/scripts/build-test-bundle.mjs index aef3840..b283e5e 100644 --- a/scripts/build-test-bundle.mjs +++ b/scripts/build-test-bundle.mjs @@ -25,7 +25,10 @@ if (gateway.protocol !== "https:" || gateway.pathname !== "/mcp" || gateway.sear } const artifactNames = [...new Set(Object.values(manifest.artifacts ?? {}).map((item) => basename(String(item.url))))]; const members = ["SHA256SUMS", "install.md", "manifest.json", "manifest.sig", ...artifactNames]; -execFileSync("tar", ["-czf", bundle, "-C", releaseDir, ...members]); +execFileSync("tar", [ + "--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "--format=ustar", + "-czf", bundle, "-C", releaseDir, ...members, +]); const digest = createHash("sha256").update(readFileSync(bundle)).digest("hex"); let prompt = readFileSync(resolve("distribution/agent-test.template.md"), "utf8"); prompt = prompt.replaceAll("{{BUNDLE_URL}}", bundleUrl).replaceAll("{{BUNDLE_SHA256}}", digest); diff --git a/scripts/release-tag-exists.mjs b/scripts/release-tag-exists.mjs new file mode 100644 index 0000000..0a6cb2f --- /dev/null +++ b/scripts/release-tag-exists.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +const tag = process.argv[2]; +if (!tag) { + console.error("用法:release-tag-exists.mjs "); + process.exit(2); +} + +let body = ""; +for await (const chunk of process.stdin) body += chunk; + +let value; +try { + value = JSON.parse(body); +} catch { + console.error("无法解析 Gitea Release 列表 JSON"); + process.exit(2); +} + +const rows = Array.isArray(value) + ? value + : Array.isArray(value?.data) + ? value.data + : Array.isArray(value?.releases) + ? value.releases + : undefined; +if (!rows) { + console.error("Gitea Release 列表 JSON 结构不受支持"); + process.exit(2); +} + +const found = rows.some((row) => row && typeof row === "object" && Object.entries(row).some(([key, entry]) => { + const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, ""); + return (normalizedKey === "tag" || normalizedKey === "tagname") && entry === tag; +})); +process.exit(found ? 0 : 1); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index 3701a14..db978f5 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -86,6 +86,11 @@ test("customer distribution is a generic single-login gateway bridge", () => { const deploy = readFileSync("deploy/install.sh", "utf8"); assert.match(deploy, /--download-base-url "\$\{gitea_release_base_url\}"[\s\S]*--download-mirror-base-url "\$\{public_download_base_url\}"/s); + assert.match(deploy, /refs\/tags\/\$\{release_tag\}[\s\S]*release_created_at_commit="\$\{release_tag_commit:-\$\{release_commit\}\}"/s); + assert.match(deploy, /! -name 'agent-test-install\.md' ! -name 'dingmcp-test-bundle\.tar\.gz'/); + + const testBundleBuilder = readFileSync("scripts/build-test-bundle.mjs", "utf8"); + assert.match(testBundleBuilder, /--sort=name.*--mtime=@0.*--owner=0.*--group=0.*--numeric-owner.*--format=ustar/s); }); test("public install page explicitly declares UTF-8 for browser rendering", () => { @@ -95,3 +100,18 @@ test("public install page explicitly declares UTF-8 for browser rendering", () = assert.match(installLocation, /charset utf-8;/); assert.match(installLocation, /charset_types text\/markdown;/); }); + +test("Gitea release lookup tolerates tea field-name corruption and fails closed", () => { + const lookup = (body: string, tag = "v1.1.0-rc.11") => spawnSync( + process.execPath, + ["scripts/release-tag-exists.mjs", tag], + { input: body, encoding: "utf8" }, + ); + assert.equal(lookup('[{"tag_name":"v1.1.0-rc.11"}]').status, 0); + assert.equal(lookup('[{"tag-_name":"v1.1.0-rc.11"}]').status, 0); + assert.equal(lookup('{"data":[{"Tag":"v1.1.0-rc.11"}]}').status, 0); + assert.equal(lookup('[{"tag-_name":"v1.1.0-rc.10"}]').status, 1); + const invalid = lookup("not json"); + assert.equal(invalid.status, 2); + assert.match(invalid.stderr, /无法解析/); +});