From 737ff48b2f7c319aae36eb5828602e60fb9b5354 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Wed, 21 Oct 2026 13:57:18 +0800 Subject: [PATCH 01/17] fix(dev): pin the web toolchain to Node 20/22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdom-based web unit tests silently break on Node 25 (window.localStorage degrades to a plain object, see #991) while CI stays green on its pinned Node 20 — a confusing failure mode for new contributors on bleeding-edge Node. Pins the local default via .nvmrc, declares engines in web/package.json, and spells out the supported versions in CONTRIBUTING. Signed-off-by: zjncs <18910855655@163.com> --- .nvmrc | 1 + CONTRIBUTING.md | 2 +- web/package.json | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..209e3ef4b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c7c57d04..dad2d10a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Prerequisites: - Docker and Docker Compose - Java 21 -- Node.js and `pnpm` +- Node.js 20 or 22 (see `.nvmrc`; Node 25+ currently breaks jsdom-based web tests — #991) and `pnpm` Start the local stack: diff --git a/web/package.json b/web/package.json index ac1340ec4..a1d9924c1 100644 --- a/web/package.json +++ b/web/package.json @@ -92,5 +92,8 @@ "typescript": "^5.7.0", "vite": "^6.4.3", "vitest": "^4.1.11" + }, + "engines": { + "node": ">=20 <25" } } From 4d2b42282c8d9012a2ffa934b68224a9440b89f5 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:58:31 +0800 Subject: [PATCH 02/17] docs(dsh): record 0.3.1 publication and acceptance Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../changes/add-dsh-native-plugin/release-notes.md | 2 +- openspec/changes/add-dsh-native-plugin/tasks.md | 6 +++--- .../changes/add-dsh-native-plugin/verification.md | 13 +++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-dsh-native-plugin/release-notes.md b/openspec/changes/add-dsh-native-plugin/release-notes.md index 8e0638c4d..c8680d27c 100644 --- a/openspec/changes/add-dsh-native-plugin/release-notes.md +++ b/openspec/changes/add-dsh-native-plugin/release-notes.md @@ -2,7 +2,7 @@ ## 版本与交付状态 -本次发布候选为 `@astron-team/skillhub-dsh@0.3.1`,计划使用独立标签 `dsh-v0.3.1`。[兼容修复 PR #988](https://github.com/iflytek/skillhub/pull/988) 已合并;npm 上传、公开包完整性及发布后安装检查尚待完成,分别记录于 [验证摘要](verification.md)。历史已发布版本保持不变。 +`@astron-team/skillhub-dsh@0.3.1` 已发布,独立标签为 `dsh-v0.3.1`。[发布准备 PR #995](https://github.com/iflytek/skillhub/pull/995) 已合并,[专用发布工作流](https://github.com/iflytek/skillhub/actions/runs/39556075587) 成功;[公开 npm 包](https://www.npmjs.com/package/@astron-team/skillhub-dsh/v/0.3.1) 与工作流产物逐字节一致,`latest` 指向 `0.3.1`。两版 Linux 的严格安装、原生探针及浏览器操作复验通过,测试工件与公开包字节一致,结果见 [验证摘要](verification.md)。历史已发布版本保持不变。 ## 0.3.1 DSH 双 RC 兼容 diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index cf82ba4b3..5b4d436a4 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -299,6 +299,6 @@ ## 38. 0.3.1 兼容修复发布 -- [ ] 38.1 将包版本及双语安装、发布说明更新为 `0.3.1`,完成候选检查与发布准备 PR。 -- [ ] 38.2 使用 `dsh-v0.3.1` 和专用工作流发布 npm,核对实际上传产物、公开版本和完整性。 -- [ ] 38.3 从 npm 下载公开包,完成隔离 DSH 安装与关键操作复验,并记录已验证平台及未验范围。 +- [x] 38.1 将包版本及双语安装、发布说明更新为 `0.3.1`,完成候选检查与发布准备 PR(#995)。 +- [x] 38.2 使用 `dsh-v0.3.1` 和专用工作流发布 npm,核对实际上传产物、公开版本和完整性。 +- [x] 38.3 从 npm 下载公开包,完成隔离 DSH 安装与关键操作复验,并记录已验证平台及未验范围。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index e9f920940..f5ad086f5 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -2,6 +2,8 @@ ## 1. 当前结论 +`0.3.1` 已发布,公开 npm 包与发布工作流产物逐字节一致,`latest` 已更新。两版 Linux 的严格安装、原生探针及浏览器操作复验通过,实际安装内容与公开 tarball 逐文件一致,未复用历史功能候选冒充新包验收;本次进展见文末记录。 + `0.2.1` 已通过 [PR #979](https://github.com/iflytek/skillhub/pull/979) 合并并发布;默认来源为 `https://skill.xfyun.cn`。`0.3.0` 开源交互改进已通过 PR #986 合并、发布并完成公开包独立验收,结果见本文发布验收记录,不以历史版本结果代替新代码验证。 2026-10-19 使用公开 npm `0.2.0` 的真实 Linux DSH 插件连接 `https://skill.xfyun.cn`,连接测试、目录、搜索、Markdown 和版本选择通过;选择 `global/scientella@20260724.052402` 实际安装到隔离项目,7 个文件及归档 fingerprint 一致,inventory 来源与目标正确,再搜索显示项目已安装旧版,DSH 发现成功。评论接口 404 按可选能力处理,安全审核 401 按匿名拒绝处理。本轮未调用模型,不据此声明技能实际执行或生产 Pod 已核验。该技能使用日期格式的非 SemVer 版本,版本比较返回 `blocked/unknown-order`,按既有策略不提供升级入口;不得把固定版本安装通过写成升级通过。升级阻断前后文件保持不变;真实卸载后 DSH 发现消失,inventory 与重新搜索的安装记录清空。公开 `0.2.0` 的正式来源基线共 9 项浏览器检查通过,无页面脚本异常。 @@ -153,3 +155,14 @@ - 使用公开 tarball,通过官方安装器装入干净隔离 Profile;复用已安装的官方 DSH 程序,运行基线和插件生命周期探针均通过。该路径不等于重新从网络安装整套 DSH 依赖。 - 现有 Linux 预览升级到公开 `0.3.0`,81 个交付文件逐字节核对;15 个历史对话归档及技能状态文件在升级和重启后保持一致。浏览器技能中心加载 3 张联调技能卡片,无页面脚本异常。 - 本轮发布后未新增模型请求,不将先前候选的真实模型验收写为公开包的新模型验收;预览使用本地开源市场,不代表 SaaS 新契约已部署。后续发布将公开读取窗口扩大到 40 次、间隔 30 秒,保留严格完整性比对与不重复发布边界。 + + +## 0.3.1 发布与公开包验收(2026-10-21) + +- [发布准备 PR #995](https://github.com/iflytek/skillhub/pull/995) 已合并,使用独立标签 `dsh-v0.3.1` 发布。PR 后端检查首次因 Go `1.27.1` 工具链下载超过 25 秒就绪期限失败;[重跑](https://github.com/iflytek/skillhub/actions/runs/39554597158) 成功,不将首次运行描述为全部通过。 +- 本地 build、typecheck、lint 及完整 365 项测试通过。[正式发布运行](https://github.com/iflytek/skillhub/actions/runs/39556075587) 最终成功,八组 DSH 检查、候选打包及 npm 发布检查通过。 +- 官方 Registry 的 `latest` 已指向 `0.3.1`;独立下载 [公开 npm 包](https://www.npmjs.com/package/@astron-team/skillhub-dsh/v/0.3.1),归档与工作流 tarball 逐字节相同,`dist.integrity` 核对一致。 +- 从公开 `0.3.0` 升级到与公开 `0.3.1` 相同字节的候选包后,技能文件、inventory、操作日志与原会话历史共 12 个文件逐字节保持,来源及详情 Tab 配置不变;重启后读取验证通过。该保留验证与全新安装生命周期验证分别记录。 +- 同一工作流产物在 Linux 官方 DSH `0.2.0-rc.2`、`0.2.1-rc.2` 中分别通过 npm/pnpm 严格安装、故意 peer 冲突负控及 5 项原生探针;公开包下载后,再与三套实际安装目录逐文件比对一致。执行时点与工件来源保留原记录,不将上传前运行改写为下载后执行。 +- 两版浏览器均完成用户/项目安装 `1.2.0` → 升级 `1.3.0` → 卸载,以及三种操作取消零写入;文件、指纹、库存、操作日志核对通过,每版实际写入 RPC 为 6 次,页面及控制台错误为 0。新 RC 首次脚本重复选择已选范围导致定位超时,发生时写入为 0;修正测试定位后,真实范围切换及完整流程通过,原失败记录保留。 +- 本轮使用隔离本地市场,没有调用模型;未新增新 RC Windows/macOS 原生文件操作、真实桌面或 SaaS 生产验收,不修改生产环境。 From 36b226416ec5bceb8387740f2a521816e29c9ab6 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:25:32 +0800 Subject: [PATCH 03/17] feat(ci): detect cross-PR Flyway version collisions Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- scripts/migration-preflight.py | 139 ++++++++++++++++++++++ scripts/tests/test_migration_preflight.py | 69 +++++++++++ 2 files changed, 208 insertions(+) create mode 100644 scripts/migration-preflight.py create mode 100644 scripts/tests/test_migration_preflight.py diff --git a/scripts/migration-preflight.py b/scripts/migration-preflight.py new file mode 100644 index 000000000..ab02cf4a6 --- /dev/null +++ b/scripts/migration-preflight.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Read GitHub PR file metadata and summarize possible Flyway version collisions.""" + +import html +import json +import os +from pathlib import Path +import re +import urllib.error +import urllib.request + + +MIGRATION = re.compile( + r"server/skillhub-app/src/main/resources/db/migration/V([0-9]+)__[^/]+\.sql\Z" +) + + +class ScanError(Exception): + pass + + +def version(path): + match = MIGRATION.fullmatch(path) + return int(match[1]) if match else None + + +def introduced_version(file): + number = version(file["filename"]) + if file["status"] == "added": + return number + if file["status"] == "renamed" and version(file.get("previous_filename", "")) != number: + return number + return None + + +class GitHub: + def __init__(self, repository, token): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ScanError("Invalid repository name") + self.base = f"https://api.github.com/repos/{repository}" + self.token = token + + def get(self, path): + request = urllib.request.Request(self.base + path, headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise ScanError(f"GitHub API HTTP {error.code}; scan incomplete") from error + except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error: + # Do not echo server payloads, tokens, request headers or arbitrary URLs. + raise ScanError(f"GitHub API {type(error).__name__}; scan incomplete") from error + + def pages(self, path, limit=None): + result = [] + for page in range(1, 1001): + separator = "&" if "?" in path else "?" + batch = self.get(f"{path}{separator}per_page=100&page={page}") + if not isinstance(batch, list): + raise ScanError("Unexpected GitHub pagination response") + result.extend(batch) + if len(batch) < 100: + return result + if limit is not None and len(result) >= limit: + raise ScanError("PR files reached GitHub's 3000-file limit") + raise ScanError("Pagination safety limit reached") + + +def scan(api): + versions = {} + errors = [] + prs = api.pages("/pulls?state=open&sort=created&direction=asc") + for pr in prs: + number = pr["number"] + try: + files = api.pages(f"/pulls/{number}/files", limit=3000) + current = api.get(f"/pulls/{number}") + if current["head"]["sha"] != pr["head"]["sha"] or current["state"] != "open": + raise ScanError("PR changed during scan; rerun required") + if len(files) != current["changed_files"]: + raise ScanError("PR file list incomplete; rerun required") + for file in files: + value = introduced_version(file) + if value is not None: + versions.setdefault(value, {}).setdefault(number, []).append(file["filename"]) + except ScanError as error: + errors.append(f"PR #{number}: {error}") + return {v: entries for v, entries in sorted(versions.items()) if len(entries) > 1}, errors, len(prs) + + +def safe(value): + # HTML text in a pre block avoids Markdown/link/table injection from filenames. + return html.escape(str(value)).replace("\r", " ").replace("\n", " ") + + +def summary(collisions, errors, count): + lines = ["## Flyway migration preflight", "", + "Advisory only: no SQL is downloaded or executed; no PR is changed.", "", + f"Open PRs inspected: {count}.", ""] + if errors: + lines += ["**Warning: scan incomplete. Absence of a reported collision is not clearance.**", "", "
", + *(safe(error) for error in errors), "", ""] + if collisions: + lines += ["**Warning: migration versions introduced by multiple open PRs:**", "", "
"]
+ for value, entries in collisions.items():
+ lines.append(f"V{value}")
+ for number, paths in sorted(entries.items()):
+ lines.extend(f" PR #{number}: {safe(path)}" for path in sorted(set(paths)))
+ lines += ["", "", "Authors should coordinate numbering and rerun migration tests before merge."]
+ elif not errors:
+ lines.append("No cross-PR version collisions found in this snapshot.")
+ lines += ["", "Scope: added migrations and renames introducing a different version in the application migration directory. "
+ "Same-version renames, modified/deleted files and conflicts with already merged migrations are not checked. "
+ "Concurrent PR changes can make this advisory snapshot stale.", ""]
+ return "\n".join(lines)
+
+
+def main():
+ try:
+ api = GitHub(os.environ.get("GITHUB_REPOSITORY", ""), os.environ.get("GITHUB_TOKEN", ""))
+ collisions, errors, count = scan(api)
+ except (ScanError, KeyError, TypeError) as error:
+ collisions, errors, count = {}, [f"Scan unavailable ({type(error).__name__}); no clearance issued."], 0
+ report = summary(collisions, errors, count)
+ destination = os.environ.get("GITHUB_STEP_SUMMARY")
+ if destination:
+ with Path(destination).open("a", encoding="utf-8") as output:
+ output.write(report)
+ else:
+ print(report)
+ return 0 # Deliberately advisory; incomplete scans remain visible in the summary.
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/tests/test_migration_preflight.py b/scripts/tests/test_migration_preflight.py
new file mode 100644
index 000000000..b40f2f4e0
--- /dev/null
+++ b/scripts/tests/test_migration_preflight.py
@@ -0,0 +1,69 @@
+"""Offline behavior tests for the read-only migration preflight."""
+import importlib.util
+from pathlib import Path
+import unittest
+
+ROOT = Path(__file__).resolve().parents[2]
+spec = importlib.util.spec_from_file_location("preflight", ROOT / "scripts/migration-preflight.py")
+m = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(m)
+PREFIX = "server/skillhub-app/src/main/resources/db/migration/"
+
+
+class PreflightTest(unittest.TestCase):
+ def test_status_and_rename_boundaries(self):
+ path = PREFIX + "V001__new.sql"
+ self.assertEqual(m.introduced_version(dict(filename=path, status="added")), 1)
+ for status in ("modified", "removed", "copied"):
+ self.assertIsNone(m.introduced_version(dict(filename=path, status=status)))
+ self.assertIsNone(m.introduced_version(dict(filename=path, status="renamed",
+ previous_filename=PREFIX + "V1__old.sql")))
+ self.assertEqual(m.introduced_version(dict(filename=path, status="renamed",
+ previous_filename=PREFIX + "V2__old.sql")), 1)
+ for path in ("tests/V1__fake.sql", PREFIX + "V1_2__other.sql", PREFIX + "V1__new.sql/no"):
+ self.assertIsNone(m.introduced_version(dict(filename=path, status="added")))
+
+ def test_multiple_collisions_sorted_and_single_pr_not_collision(self):
+ class API:
+ def pages(self, path, limit=None):
+ if path.startswith("/pulls?"):
+ return [dict(number=n, head=dict(sha=str(n))) for n in (9, 2)]
+ return [dict(filename=PREFIX + f"V{v}__a.sql", status="added") for v in (20, 3, 3)]
+ def get(self, path):
+ return dict(head=dict(sha=path.split("/")[-1]), state="open", changed_files=3)
+ collisions, errors, count = m.scan(API())
+ self.assertEqual(list(collisions), [3, 20])
+ self.assertEqual(errors, [])
+ self.assertEqual(count, 2)
+ report = m.summary(collisions, errors, count)
+ self.assertLess(report.index("PR #2"), report.index("PR #9"))
+ self.assertEqual(report.count("PR #2:"), 2)
+
+ def test_incomplete_never_reports_clearance(self):
+ report = m.summary({}, ["API HTTP 403"], 3)
+ self.assertIn("scan incomplete", report)
+ self.assertNotIn("No cross-PR", report)
+
+ def test_untrusted_filename_is_text(self):
+ attack = PREFIX + "V1__\n::error::x.sql"
+ report = m.summary({1: {2: [attack], 3: [PREFIX + "V1__a.sql"]}}, [], 2)
+ self.assertNotIn("`, prompt: '