dsh-plugin-cicd(DSH 插件发布台)
把 **DSH 插件的持续构建与发布(CI/CD)**搬进 DeepSeek Harness:侧边栏一个图标,点开是一个占满主区域的独立面板。
名字:界面显示名为「DSH 插件发布台」(副标题「持续构建与发布(CI/CD)」;英文界面为
DSH Plugin CI/CD)。包名与仓库 id 保持dsh-plugin-cicd—— 它们只是技术标识,改名会破坏已有安装(profile 的link:路径、bundles 条目、patch 里的 row id、配置文件路径)。
它回答四个 GitHub 页面各自只说了一部分的问题:
- 构建过了吗 —— 每个仓库最近一次运行的状态、耗时、触发事件。
- 发布了吗 —— 已发布的 Release、还在草稿箱里的 Release、资产清单。
- 本地领先于发布吗 —— 本地
package.json版本、领先/落后提交数、未提交文件数。 - 装的是发布出去的那份吗 —— 本机 profile 里这份是
link:的本地检出、还是 Release 上的 tgz;差几号版本。 - npm 上是哪一版 —— 这个版本在不在公开源上;不在的话,一次点击就能推上去。
也就是说:一个包,两条分发渠道。GitHub Release 给人下载与审阅,npm 让 dsh plugin add <名字> 一条命令装完。
面板上能直接做的动作,行上的顺序就是工作的顺序:提交(git add -A + commit + push)、构建(触发 ci.yml)、发布(触发 release.yml,默认产出草稿)、升版本并发布、公开发布草稿、推送到 npm vX(+ 在面板里写 npm token)、装 Release vX / 更新到 vX、重跑、取消、看失败日志,以及更新之后的 立即重启 DSH。
提交 为什么排在最前面:构建 和 发布 都作用于 GitHub 上的那个提交——workflow 跑的是推送上去的 commit。所以只存在于工作区、或只存在于本机的改动,它们都看不见。面板一直在警告这件事(脏工作区那行、↑N 那个 chip),在这之前唯一的出路是开终端。它永远不是主按钮:它是步骤,不是目的。
提交 一个按钮两种含义,因为两者是同一个陷阱的两半:有改动 → 提交并推送;工作区干净但领先上游 → 只推送(按钮此时显示推送);两者皆无 → 按钮禁用并写明原因。git add -A 是刻意的(发布也需要新增文件),所以确认框会把待提交的文件名列出来——"7 个改动"可能是你要的 3 个加上你没见过的 4 个。提交信息为空时确认按钮保持禁用。
安装
本机 profile 用 link: 指向检出目录,和其它插件一致:
dsh plugin --profile desktop add "link:F:\CodeProj\dsh-plugin-cicd"
装完需要重启 DSH 才会被组合进运行时(宿主半边与浏览器半边都要)。
零代码操作(给不写程序的人)
面板的目标是只用鼠标就能完成日常使用。凡是下表打了勾的,都不需要打开终端:
| 要做的事 | 怎么做 | 需要命令行吗 |
|---|---|---|
| 登录 GitHub | 面板顶部的「用浏览器登录 GitHub」→ 打开授权页 → 输入面板显示的一次性代码 | 不需要(Host 替你跑 gh auth login --web,把码显示出来) |
| 补齐权限 | 同一处的「补齐权限」,走同样的浏览器流程 | 不需要 |
| 添加仓库 | 「管理仓库」→ 从你的仓库列表勾选(本地检出自动匹配) | 不需要 |
| 移除仓库 | 取消勾选 | 不需要 |
| 触发构建 | 仓库那一行的「构建」 | 不需要 |
| 发布(一键:代码推送 + Release + npm) | 仓库那一行的「一键发布」→ 确认框(脏工作区就填提交信息,2FA 就填一次性密码)→「确认发布」 | 不需要(Host 起一条流水线,跟着 CI 走完:升版本、推 npm、公开 Release、B 站播报都由它自己接着做) |
| 只出草稿(不要一键) | 配置里关掉 oneClickRelease,「发布」回到只触发构建 |
不需要 |
| 公开草稿(手动路径) | 展开行 →「公开草稿」→「确认公开?」(两次点击) | 不需要 |
| 给 npm 一个 token | 面板顶部(未登录时才出现)粘贴 Access Token →「写入并验证」 | 不需要(Host 写进 npm 自己读的 ~/.npmrc,插件不保存 token) |
| 单独推送到 npm(手动路径) | 展开行 →「推送到 npm vX」→「确认推送」(两次点击;开了 2FA 就在同一行填一次性密码) | 不需要(Host 用 DSH 自带的 pnpm 发布,复用同一个包管理器) |
| 更新已装的那份 | 行上的「更新到 vX」(只有 profile 装的是更旧的发布包时才有;link: 检出与"装它会降级"两种情况的按钮在展开处)→ 展开处「确认更新」 |
不需要(Host 把 tgz 交给 DSH 自己的插件管理器安装) |
| 让新版生效 | 更新后面板直接问「现在重启?」→「立即重启 DSH」 | 不需要(转发给 dsh-plugin-restart;没装它会明确说) |
| 看失败日志 / 重跑 / 取消 | 展开行里的对应按钮 | 不需要 |
| 账号、权限、仓库清单 | 设置 → 插件 → DSH 插件发布台 → GitHub | 不需要 |
仍然需要命令行的两件事,如实列出,不假装不需要:
- 安装这个插件本身(一次性)——见下一节。
- 打 tag(只有想走 tag 触发那条路时)——版本号由【一键发布】自己升(版本已被别的提交占用时),但
git tag vX && git push origin vX仍是发布之外的动作,面板不做。
「更新」装完要重启才生效,这一步面板会主动问(见下文《更新已安装的插件》);重启由 dsh-plugin-restart 执行,发布台自己不碰进程。
「更新」装完要重启才生效,这一步面板会主动问(见下文《更新已安装的插件》);重启由 dsh-plugin-restart 执行,发布台自己不碰进程。
连 gh 都没装时,面板给下载页链接;而安装 gh 这一步在 Windows 上目前仍需一次终端(或用安装包)。
别人怎么装(下载即用)
Release 里有两个资产,回答两个不同的问题:
| 资产 | 用途 |
|---|---|
dsh-plugin-cicd-<version>.tgz |
可安装的那份:一条命令装,不用解压、不需要本地检出 |
dsh-plugin-cicd-<version>.zip |
整棵仓库树,给人看/审/做 diff |
SHA256SUMS.txt |
上面两个的校验和(用途是"确认你下载的没坏",不是防篡改——见 RELEASING.md) |
# 1. 下载 tgz(或从 Release 页面直接下)
gh release download v0.1.0 -R SOH4C4759/dsh-plugin-cicd -p '*.tgz'
# 2. 装进你的 profile(把 desktop 换成你自己的)
dsh plugin --profile desktop add "file:$PWD\dsh-plugin-cicd-0.1.0.tgz"
# 3. 重启 DSH,侧边栏出现「发布台」图标
file: 指向本地的 tgz,pnpm 会解包进 profile 的 node_modules。这条路径已经在发布资产上实测过:解包后 scripts/verify-bundle.mjs 与 tests/host-checks.mjs 都能跑通,cordis.patch.yml 与 scripts/configure.mjs 都在包里(npm pack 只带 files 白名单,CI 每次都验这两点)。
更新已安装的插件(把上一条从终端搬进面板)
上面那三行命令,现在就是面板上一个按钮:「装 Release vX」/「更新到 vX」。它做的正是那三行的前两步,第三步(重启)由面板接着问。
每一行都会先回答"这个 profile 里现在装的是什么",因为版本号相等不代表同一份代码:
| profile 里的依赖 | 面板的判断 | 行上有没有按钮 |
|---|---|---|
link:F:\...(本地检出) |
本地检出 —— 跑的不是发布出去的那份,即使版本号与 Release 完全相同 | 没有。展开处有次要按钮【装 Release vX】(+ 说明会替换掉什么),因为这台机器是开发这份插件的地方,行上的按钮只会把正在改的代码换成 tgz |
file:...\x-1.2.0.tgz,装的是 1.2.0,Release 是 1.3.0 |
可更新 —— 行上出现「可更新 v1.3.0」标记 | 有,【更新到 v1.3.0】——这是行上唯一会被摆出来的安装动作 |
| 装的是 2.0.0,Release 是 1.3.0 | 面板明说装它会往回退,不假装是"更新" | 没有(等于把降级当日常维护)。展开处仍可点,但只是次要按钮 |
为什么把行让出来:面板的行是"你现在要做的事"。对插件作者自己来说,link: 是常态,装 Release 不是要做的事,而是取消掉让开发能进行的那套设置;而 ahead(profile 里更新)那种情况,行上摆按钮等于把降级摆在最显眼的位置。这个能力保留在展开处——"发布出去的那份到底装不装得起来"仍然要能在这里回答——只是不再叫卖。
| 装的是 1.2.0-rc.1 | 两边无法比较,如实说不能比较,而不是猜一个先后 |
| 版本相同、且就是 Release 的 tgz | 已是最新,没有按钮 |
| 不在这个 profile 的依赖里 | 未安装 —— 没有"已装的那份"可以更新,并写出找的是哪个 profile |
| 还没有已发布的 Release(草稿不算) | 没有可装的 |
装的时候:
gh release download <tag> -p <tgz>把资产下到插件自己的目录<DSH_HOME>\dsh-plugin-cicd\downloads\(不是临时目录——profile 的依赖会指向这个路径,下完就删会让清单再也装不上)。- 交给 DSH 自己的插件管理器(
ctx.get('pluginManager').installBundle),也就是dsh plugin add走的同一条 pnpm 路径:它持有 profile 锁,失败时把package.json/pnpm-lock.yaml还原。发布台不自己起一个dsh plugin子进程——那会是对同一个 profile 的第二个写入者。 - 面板接着问要不要重启(不假设答案)。重启转发给
dsh-plugin-restart;没装这个插件时回一句明确的「没挂载」,而不是一个看不出所以然的失败。
一次只跑一个更新(再来一个回 409 busy),因为插件管理器本来就会排队,而两个转圈比一个明确的拒绝更难懂。
没有装它就没有这部份:update 路由需要 Host 提供 pluginManager 服务,没有就回 501 plugin-manager-missing,而不是假装装过。
发布同台:一次点击 = 代码推送 + Release + npm
按下「发布」就是三件事一起做完,因为分开做留下了三种"看起来成功"的半成品:没人公开的草稿、npm 上有版本而 GitHub 上没有 Release、Release 构建的是一个 GitHub 根本没收到过的提交(工作区里的改动、或只提交在本机的改动,两个 workflow 都看不见)。
顺序不是实现细节,是这个功能的全部:
代码推送 → 版本号 → 触发构建 → 等待构建 → npm 推送 → 公开 Release → B 站播报
- 代码推送最前,因为
ci.yml与release.yml跑的都是推上去的那个提交。有改动就git add -A+ commit + push(需要一条提交信息,确认框里填;确认框把改动文件名列出来,因为这是面板上唯一能扫进你没打算发布的东西的动作)。工作区干净但有本地提交就只 push,两者都没有就跳过这一格。 - 版本号第二,因为它要写一个提交,而升版本拒绝脏工作区——顺序反过来没有意义。这一格做的是「为什么点发布曾经必然失败」那件事:
release.yml拒绝复用属于另一个提交的版本,所以流水线先问(releasePreflight),真被占用就自己升一个补丁号、提交、推送。 - 触发构建明确要草稿(
-f draft=true)。草稿只有作者看得见,这正是后面两格能成立的原因。 - npm 在公开 Release 之前,这是关键:npm 推送不可逆(同一版本不能推第二次),而草稿可以。所以 npm 失败时——缺一次性密码、token 失效、源连不上——Release 仍然是草稿,对外全有或全无,补上原因(或选择「跳过 npm 并公开 Release」)就能继续。反过来先公开就会留下"Release 出来了但 npm 上没有",那是重试也收拾不干净的中间态。
- B 站播报最后,因为「这个版本上线了」只有在它真的上线之后才成立。
面板上看得见它走到哪一格:七格状态(待做/进行中/完成/跳过/停住/失败/已停止)随 30 秒轮询刷新,gh run 的链接就在旁边。停住不是失败——那一行会给出两条件路:【继续】(补一次性密码,或修好 token 再来)与【跳过 npm 并公开 Release】(npm 本来就不是这个仓库的渠道,或者它暂时连不上,而 Release 不该因此永远不出门)。
不需要 npm 的仓库照样能发:private: true、没有 package.json、名字没占——这些是"不适用"而不是"坏了",那一格记【跳过】然后继续公开 Release。版本已经在 npm 上了同理(目标状态已达成)。
关掉它:oneClickRelease: false 写在 profile 的 cordis.patch.yml 那一行(见《配置》),面板的「发布」就回到只触发构建,npm 与公开草稿又是单独的按钮——某个仓库的 Release 必须一直是草稿时,这是那条退路。
流水线的记录落在 <DSH_HOME>\dsh-plugin-cicd\pipelines.json,"跑到哪一格"就是这个文件回答的:DSH 在构建期间关掉,重启后它会接着查那次运行、继续推 npm、继续公开 Release。这个文件读不出来时一键发布会拒绝启动(而不是覆盖掉一条跑到一半的记录),理由和播报台账一样:那一格里可能有一条已经建好的草稿,丢了就再也没人去公开它。
它不是什么:不是 CI 的替代品(门禁仍然在 workflow 里),也不给 npm 带 provenance——需要可验证构建来源的包应该在 CI 里用 trusted publishing 发(见下节)。
推送到 npm(第二条分发渠道)
Release 是给人下载的,npm 是给 dsh plugin add 装的。面板把它们并排放:一行上既有 GitHub 的 tag,也有 npm 上的版本。
没有一键发布时(oneClickRelease: false),npm 是独立按钮,不和「发布」绑在一起——推送不可逆,不该搭在另一个动作上顺带发生。一键发布之所以能把它带上,是因为顺序:npm 排在公开 Release 之前,所以拒一次推送不会留下任何公开的东西。
每一行先说清 npm 现在是什么状态:
| 面板看到 | 判定 | 按钮 |
|---|---|---|
| 这个版本已在 npm 上 | 已发布 | 没有按钮(npm 不允许同一版本推第二次) |
| 名字在 npm 上还没人占 | 未注册 | 【推送到 npm vX】——这次就是首次发布 |
| 名字已存在,但这个版本没推过 | 未发布 | 【推送到 npm vX】 |
| 问不到源(离线/被拦) | 未知 | 没有按钮——宁可不给,也不给一个注定失败的 |
推送前会逐条拒绝,且每一条都发生在任何字节上传之前:
private: true—— 作者的决定,不是这里能绕过的(本仓库集里的dsh-knowledge-console就是这种)。- 工作区有未提交改动 ——
publish打包的是工作目录;files白名单挡不住"白名单目录里的新文件"。 - 这个版本已经在 npm 上 —— npm 不接受同一版本推两次,撤回也只在很短的时间内可行。
- 没有登录 npm —— 面板直接给出解决路径(见下)。
- 没有本地检出 / 没有版本号 / 读不到源。
凭据:和复用 gh 同一个原则
插件不保存任何 token。它用 DSH 自带的 pnpm(profileContext.packageManager,也就是插件管理器安装时用的那个),凭据来自 npm 自己读的用户级 .npmrc。
gh 那侧不需要教:它自己开浏览器设备码流程。npm 这侧不一样——token 得手动在网站上建,而且那条路的 UI 在 2025 年 11 月变过。所以未登录时面板给的是从零开始的四步,不是一句话:
| 步 | 面板说什么 |
|---|---|
| ① 没有账号 | 「打开 npmjs.com 注册」按钮 + 注册后必须到邮箱点确认链接(未验证邮箱的账号发不出去) |
| ② 建 token | 「打开 token 页面」→ npmjs.com/settings/~/tokens。类型只有 Granular Access Token 一种可选:Classic token 已于 2025-11-19 被 npm 全部撤销,现在也建不出来。Permissions 选 Read and write,Packages 选全部或只勾这个包 |
| ②a 有效期 | 最长 90 天,这是 npm 对可写 token 的硬限制。到期就静默失效,回到第 ② 步再建一个 |
| ②b 2FA | 只有"给 CI 用、没人能输一次性密码"时才勾 Bypass 2FA。在这里推不要勾 |
| ③ 粘贴 | 写进 %USERPROFILE%\.npmrc(就是 npm login 会写的那个文件;其他行原样保留,旧版本留一份 .bak),然后立刻用 whoami 验证 |
| ④ 2FA | 推送的确认行里会出现一次性密码输入框 |
token 不回显、不落插件、不进日志;页面在请求返回的那一刻就清空输入框。token 被拒时,面板直接把排查清单摊在同一块里(复制不全 / 权限不是 Read and write / 已撤销或超过 90 天 / 范围是别的包 / 邮箱未验证),而不是只回一句 ERR_PNPM_WHOAMI_UNAUTHORIZED。
如果 npmjs.com 在你这台机器上打不开——实测过:整站被 Cloudflare 挑战拦下(/、/signup、/settings/~/tokens 一起回 403 Just a moment...,走本地代理也一样)——那就换一台设备或换一个网络(手机流量最省事)生成 token,再粘到第 ③ 步。面板只认这个 token,不在乎它在哪里生成,这一步和你本机的浏览器无关。
同时别再指望命令行:POST registry.npmjs.org/-/user/org.couchdb.user:* 现在回 405 Method Not Allowed,也就是 npm login --auth-type=legacy / pnpm login 这条路已被 npm 移除;而建 token 的 POST /-/npm/v1/tokens 虽然还活着并真在认证(假凭据回 404 User not found),但 npm 公告写明新 classic token 不能再经 API 创建。所以网站(或在另一台设备上打开它)是唯一的路。发布本身不受影响:registry.npmjs.org 一直是通的。
凭据是三个状态,不是一个布尔值(npmAuthState)。whoami 是用户级端点,而 granular token 是包级范围的——它完全可能在 whoami 上被拒、却发布得好好的。把这种拒绝读成"没登录",就会正好挡住引导让用户去建的那种 token,还会对刚做完第 ③ 步的人说"你还没开始"。所以:
| 状态 | 面板 |
|---|---|
signed-in(whoami 答了) |
顶部显示 npm <账号>,不出引导 |
credential-present(.npmrc 里有 token,但 whoami 不确认) |
一句说明 + 原样显示 whoami 的拒绝,照着可以推;真伪由推送来判 |
none(两者都没有) |
四步引导 |
没有答案(/npm-status 未响应) |
什么都不显示——"没有答案"不等于"没有凭据" |
同理,推送的闸门是"有没有可用来认证的东西",不是"whoami 答没答":token 不对的话推送本来就会失败,而失败已经被归类成下一步。
推送失败也归类成下一步做什么,而不是把 npm 的原话丢给用户:otp-required(去填一次性密码)、email-unverified(去点确认链接)、not-logged-in(重建 token)、already-published(先升版本)、payment-required(私有包要付费)、forbidden(检查 token 的 Packages 范围)、not-found、rate-limited、registry-error、network、timeout、unknown。npm 的原话仍然原样显示在下面——归类是补充,不是替换。
开了 2FA 的账号,推送时 npm 要一次性密码:确认行里就有那个输入框。子进程的 stdin 是关闭的(ignore),所以它不会挂着等一个没人持有的 stdin——失败会明说是要 OTP,而不是转圈到超时。
另一条路:完全不用 token(trusted publishing)
如果你的目标是"CI 里自动发布",不该用上面这条路。npm 支持 trusted publishing(GitHub Actions OIDC):release.yml 加 id-token: write 权限并在 npm 上登记这个仓库,发布时用 OIDC 换一次性凭据——没有任何长期 token,还自带 provenance。这与本插件"不存凭据"的原则完全一致,也是本仓库更推荐的方向。
面板不做这件事:它是在这台机器上推一次的路径,不是 CI 的替代品。CI 发布需要 token 时,token 应放在仓库 secret 里(NPM_TOKEN),而不是这台机器上。
B 站更新播报(第三条渠道)
插件更新了,除了 Release 和 npm,还有一件观众真的会看的事:在介绍这个插件的视频下面留一句"更新了什么"。这一条也做进面板里——每个仓库绑一个 BV 号,Release 公开之后自动在评论区补一条更新说明。
触发条件只有一个:Release 已公开。 草稿不算——草稿只有作者能看见,为它发"上线了"是对着空气说话。所以:
- 面板每
bilibiliWatchSeconds(默认 90 秒)巡检一次每个已绑定的仓库; - 点【公开草稿】成功后立刻巡检一次(这一下就是"发布成功"的那一刻);
- DSH 启动约 15 秒后巡检一次——关机期间发布的那些版本,只有这一次能补上;
- 幂等键是
(仓库, tag),落在<DSH_HOME>\dsh-plugin-cicd\bilibili-announcements.json里。同一个版本永远只发一条;这份记录读不出来时什么也不发(否则会把已经发过的评论再刷一遍),面板会把原因写在最上面。
绑定视频时会写一条基线:绑定那一刻已经公开的版本不算"这次更新"。所以把视频接上来不会追发历史版本,只有之后的新版本会播报。
"发送成功"不等于"读得到",所以发送后要复核。 x/v2/reply/add 会为一条公开列表里根本读不到的评论回 code: 0 加一个 rpid——实测有两条被记成"已播报",按 rpid 查回却都是 12006 没有该评论。所以:
- 发送后 Host 按 rpid 查回(
x/v2/reply/reply?root=<rpid>),把结果写成台账里的visible(true/false;null= 发送失败没问过;字段缺失 = 早于这个功能的旧记录)与visibleCode; - 必须重试,不能只查一次:刚发出的评论头几秒查回的是
12006,约 4 秒后才可读(实测 +0s 失败 / +4s 成功),所以是"查 → 等 3 秒 → 再查",默认 3 次。一次性的即时检查会把一条好好的评论判成不可见,那比不检查更糟; - 面板据此在"已播报"旁给出 warn chip【已发送但读不到】,发送后的提示也照实说;
- "审核中"与"已被删除"在接口上无法区分(都是
12006),所以不做自动重发——评论可能还在审核,自动重发会变成重复评论。要重发就人工走【仍然发送】。 - 判断某条评论在不在,只能用
x/v2/reply/reply精确查找;x/v2/reply与x/v2/reply/wbi/main这两个列表端点实测只回 1 条(all_count=6却只给 1 条),拿列表判断会误判。
凭据:为什么必须是"网页登录"
发评论走的是 B 站 Web 接口,所以凭据必须是网页会话。这里踩过一回,记在文档里免得再踩:biliup login 写下的 cookies.json 是 BiliTV 登录(platform: BiliTV)——它能投稿(APP 接口),但所有 Web 会员接口一律回 -101 账号未登录。所以本插件不假设"文件里有 SESSDATA 就能用",而是拿凭据去问一次账号接口,并把答案写在面板上;认不出来时给出的原话是"这份凭据是 BiliTV 登录(APP/TV)……",不是"你没登录"。
凭据有两种给法,面板里都能做,不需要终端:
【登录 B 站】:走 passport 的网页接口拿一个二维码。码是插件自己画的——用手机 B 站 App 扫它,然后在手机上确认。面板轮询到确认后把 Cookie 存进插件自己的文件。
面板不提供"打开登录页面":那串 URL 是给手机看的 H5 页,在电脑浏览器上打开只会给你一个 B 站 App 的 APK 下载,不是登录。所以扫码是唯一的路,扫不动就用下面第 2 条的粘贴方式。
为什么要自带一个二维码编码器:登录接口交回来的是一串要被手机扫的 URL,而客户端半边只有 React 等五个内建、没有任何依赖。用外部二维码图片服务等于把一条活的登录 URL 交给第三方(泄凭据);加依赖则违背"装一个 tgz、不拉依赖树"。所以
client.js里有一段 byte 模式、纠错等级 M、版本 1–10 的编码器,画成内联 SVG。它拿 profile 里现成的qrcode包(那是别的包的依赖,不是本插件的)逐模块验证过,并把基准矩阵冻结成测试断言——任何一个模块翻转都会让测试红。粘贴一次:浏览器 F12 → Application → Cookies →
bilibili.com,把SESSDATA与bili_jct(即 csrf)复制进来。B 站当场接受才写入——存一份已经回-101的凭据,只会让面板显示一个假的"已登录"。
两者都落在 <DSH_HOME>\dsh-plugin-cicd\bilibili-cookies.json(bilibiliCookieFile 可以指向别处,比如 biliup 那份,作为兜底读取;面板自己写的永远优先)。插件不回显这个文件的内容,【退出 B 站登录】也只删自己写的这一份——外部那份属于别的工具,不动。
评论内容
默认模板是 【更新 {tag}】{summary}:{summary} 取 Release 标题;标题就是版本号时改取 Release 正文第一段(去掉 Markdown、去掉 GitHub 自动生成的 "What's Changed / Full Changelog" 和 by @someone in https://… 尾巴)。可用占位符:{tag} {version} {label} {repo} {title} {summary} {url} {date};不认识的占位符留在原地并报出来,不会被悄悄删掉。
默认模板不带链接:带外链的评论更容易被 B 站过滤,而被过滤和发成功在这边看起来一模一样。想要链接就把 {url} 写进 bilibiliTemplate。
发之前一定先预览:面板上点【发更新评论】会先问 Host "这条会写成什么"(dryRun),把即将发送的原文和 Host 的判决一起显示出来,确认后才真的发。预览由 Host 组装,不是浏览器——否则会出现"看到的是一句、发出去的是另一句"。
失败怎么处理
B 站的拒绝会被归类,而不是原样抛给用户:-101 未登录、-400 被拒、-403 无权限、-412 风控拦截、-509 频率限制、12061 内容被过滤……每一类给的是下一步做法。失败会记进播报记录;同一个版本失败 3 次后就停下等人工判断——风控拦下来的东西,连续重试只会更糟。
配置:用脚本,不要手改 YAML
包本身不带仓库列表——哪些仓库被监视是部署状态,不是包状态;把某个人的私有仓库名打进公开包,每个安装者都得先去拆它。
列表放在一个由脚本管理的 JSON 文件里(默认 %USERPROFILE%\.dsh\dsh-plugin-cicd\repos.json),profile patch 只留两行机器相关的配置:
- id: dsh-plugin-cicd
config:
owner: SOH4C4759
ghPath: 'C:\Program Files\GitHub CLI\gh.exe'
日常操作全走插件自带的脚本:
$cfg = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-plugin-cicd\scripts\configure.mjs"
# 或直接用你的检出目录:F:\CodeProj\dsh-plugin-cicd\scripts\configure.mjs
node $cfg list # 现在登记了什么
node $cfg add dsh-plugin-restart --path F:\CodeProj\dsh-plugin-restart
node $cfg add someone-else/their-plugin # 别人的仓库,只读
node $cfg remove dsh-plugin-restart
node $cfg owner SOH4C4759
node $cfg check # 面板此刻会显示什么
- 插件每次请求都重读这个文件,所以
add在面板下一次轮询(默认 30 秒)就生效,不需要重启。 - 写入是原子的(临时文件 + rename),旧版本留一份
.bak。 - 校验与宿主同规则:仓库名必须是
name或owner/name,--path必须是绝对路径——相对路径会按宿主进程的目录解析,那不是你的 shell 目录。 - 文件读不了或格式错时,面板会明确报错而不是假装"没有仓库",并回退到 patch 里的
repos。
patch 里仍可用的选项(每个都有默认值):
| 键 | 默认 | 含义 |
|---|---|---|
owner |
'' |
裸 repo 名用的 GitHub 账号;配置文件里的同名值优先 |
repos |
[] |
手写兜底;配置文件存在时以文件为准 |
configFile |
<DSH_HOME>\dsh-plugin-cicd\repos.json |
受管列表的位置 |
projectsRoot |
'' |
本地检出所在根目录;面板添加仓库时据此自动填 localPath(按各目录 package.json 的 name 匹配,所以目录名与仓库名不同也能找到),克隆【克隆】的落点也是这里。这是行配置(cordis.patch.yml),不是 repos.json 的键——写进后者会被忽略 |
defaultBranch |
main |
面板触发 workflow 用的 ref |
buildWorkflow |
ci.yml |
构建 按钮触发的 workflow |
releaseWorkflow |
release.yml |
一键发布触发的 workflow;关掉一键发布后是 发布 按钮触发的那个 |
oneClickRelease |
true |
发布同台:发布 = 代码推送 + Release + npm 一条流水线。设 false 就回到"只触发构建(出草稿)",npm 与公开草稿各自是独立按钮 |
pipelineWatchSeconds |
20 |
一键发布流水线的推进间隔(每次最多一次 gh run list);0 停掉时钟,只保留按需推进(DSH 重启后的补跑也会停) |
ghPath |
'' |
空 = 从 PATH 与 %ProgramFiles%\GitHub CLI\gh.exe 等位置解析 |
requestTimeoutMs |
20000 |
单次 gh 调用的硬超时 |
overviewTtlMs |
15000 |
概览缓存窗口;面板轮询会走缓存 |
logTailLines |
120 |
失败日志取最后多少行 |
pollSeconds |
30 |
面板自动刷新间隔 |
npmRegistry |
https://registry.npmjs.org/ |
推送目标源。改成私有源即可(只能 http/https,且 URL 里带凭据会被拒绝);.npmrc 里的 token 行按这个源的 host 匹配 |
enabled |
true |
关掉后路由只回「已停用」,不碰 GitHub |
所有值都做夹取而不是拒绝:手写的 patch 不该能让宿主起不来,最坏情况是某个路由报告「未配置」。
首次使用:授权引导
面板不自己存凭据,它用你机器上的 gh。所以第一次打开时它可能无事可做——这时面板不会只显示一句"读不到",而是按缺什么给什么:
| 状态 | 面板给出 |
|---|---|
找不到 gh |
winget install --id GitHub.cli(可一键复制)+ cli.github.com 链接 |
| 装了但没登录 | gh auth login,并说明选 GitHub.com → HTTPS → 浏览器登录,完成后点「重新检测」 |
| 登录了但缺 scope | gh auth refresh -h github.com -s repo,workflow,缺哪个补哪个 |
| 没有任何仓库 | configure.mjs add ... 的实际命令 + 配置文件路径 |
scope 是从 gh auth status 真读出来的:缺 repo 读不到私有仓库,缺 workflow 无法触发构建——这两种情况在按钮按下去之前就会说明白。细粒度 token 不报 scope 行时按"未知"处理,不会误报成"全都缺"。
为什么复用 gh 而不是自带 token
本机 gh 已经登录、已经带好了 token scope。复用它意味着:插件不存任何凭据,不需要你再走一次 OAuth/PAT,而面板执行的命令就是你会手敲的那条(gh run list / gh workflow run / gh release edit)。反过来,任何要求你再输一次 token 的方案,都是把同一条权限链复制了第二份。
npm 这一侧是同一条原则的两个面:包管理器复用 Host 自己的 pnpm(profileContext.packageManager——插件管理器安装插件时用的就是它,第二个答案早晚会和第一个不一致),凭据放在 npm 自己读的 .npmrc 里。面板提供了一个写 token 的入口,但写的是那个文件,不是插件的任何存储:这与"面板能登录 GitHub 但插件不存 token"是同一句话。
调用一律用 execFile 传 argv、不经 shell;仓库名、workflow 名、tag 在进入 gh 之前都按 slug 形状校验过。子进程带 GH_PROMPT_DISABLED=1,否则一个想提问的 gh 会挂在没人持有的 stdin 上,面板只会转圈到超时。
安全边界
- 所有路由都是 POST + 仅回环 + 同源(
isTrustedRequest),与宿主设置桥对自家回环路由的信任策略一致:只有「来自本机」且「来自这个 Host 服务的文档」的请求能过。这些路由以本机 GitHub 凭据行事,所以不能只按端口放行。 - 只读部分:状态、概览、运行、日志、npm 状态。有副作用的是十条:
dispatch、run-action、release-action、version-bump、commit、clone、update、restart、npm-login、npm-publish。 clone是唯一会从网络拉一个新目录的路由,服务"仓库已登记但没有本地检出"这个状态——那种状态下本页每个动作都用不了(提交没东西可提交,构建与发布作用于 GitHub 上的提交而不是这份磁盘)。地址由仓库名推导(owner/repo自带,裸名用配置里的owner),面板把它预填好、可改(fork、私有远端都是真实需求);落点是projectsRoot/<仓库名>,也就是findLocalCheckout之后会去找的地方。已有可用检出回409 already-cloned,目标目录非空回409 target-exists,没有projectsRoot回409 no-projects-root(并点名这个配置键)。version-bump只改package.json的版本行,然后git commit只提交这一个文件并推送当前分支。工作区不干净、分支没有上游、或落后于上游时它直接拒绝,不做任何写入。commit是唯一会提交整个工作区的路由(git add -A+ commit + push)。工作区干净且与上游同步时回409 nothing-to-commit;message为空且确实有改动时回400 message-required(先拒绝,不写任何东西);分支没有上游时回409 no-upstream——发布构建的是 GitHub 上的提交,推不上去就等于没提交。提交信息为空但工作区干净是合法用法,含义是「把已经提交的推上去」。update是唯一会改 profile 依赖的路由:下载 Release 里的 tgz,再交给 Host 的插件管理器安装;失败时由管理器还原package.json与 lockfile。restart自己不重启任何东西:它把请求转发到同一个 Host 上的/api/dsh-restart/restart,由dsh-plugin-restart决定停哪个进程、用什么命令拉起来。没有那个插件就回501 restart-unavailable。npm-login是唯一会写用户级配置的路由:把 token 合并进~/.npmrc(其他行原样保留,旧版本留.bak)。token 绝不出现在任何响应、日志或状态里——npm-status只回答"有没有那一行",不回答"那行是什么"。npm-publish是另一个不可逆动作:npm 不允许同一版本推两次,撤回也只在很短的时间内可行。所以它也要两次点击,且推送前逐条拒绝(private: true、脏工作区、版本已存在、未登录)。公开发布草稿与npm-publish仍是仅有的两个不可逆动作,各自单独用时都要两次点击、中间那一步明说后果。一键发布把它们放进一条流水线,那一格确认框就是"中间那一步":它把七格顺序、要提交的文件、以及「npm 通了才公开 Release」写在同一屏上,再让人按下去。
HTTP 接口
面板用的就是这些,脚本也可以直接用:
| 路由 | 作用 |
|---|---|
POST /api/dsh-cicd/status |
gh 身份与解析后的仓库列表 |
POST /api/dsh-cicd/overview |
每个仓库的运行 + Release + 本地 git 状态({force:true} 绕过缓存) |
POST /api/dsh-cicd/runs |
{repo, limit} 单仓库运行列表 |
POST /api/dsh-cicd/dispatch |
{repo, workflow?, ref?, inputs?} 触发 workflow_dispatch;触发发布流程时会先做发布预检(见下节) |
POST /api/dsh-cicd/run-action |
{repo, runId, action},action ∈ rerun / rerun-failed / cancel |
POST /api/dsh-cicd/release-action |
{repo, tag, action},action ∈ publish / delete(delete 有意不给按钮) |
POST /api/dsh-cicd/version-bump |
{repo, release?},release ∈ patch(默认)/ minor / major:升 package.json、提交、推送当前分支 |
POST /api/dsh-cicd/release-all |
{repo, message?, otp?}:一键发布——代码推送 → 版本号 → 触发构建(草稿)→(流水线自己继续)等 CI → 推 npm → 公开 Release → B 站播报。开工前能本地判定的全部判定完(无检出 / 无上游 / 落后上游 / 脏工作区没给信息 → 400/409 且一个字节都没写);已经开跑的失败用 200+value.pipeline 如实回报,status 是 blocked 时 Release 仍是草稿 |
POST /api/dsh-cicd/release-all-resume |
{repo, otp?, skipNpm?}:继续一条停在 npm 的流水线。skipNpm: true = 不管 npm 了,直接公开 Release。没有停住的流水线回 409 nothing-to-resume |
POST /api/dsh-cicd/release-all-cancel |
{repo}:不再跟进这条流水线(GitHub 上的运行不会被它停掉) |
POST /api/dsh-cicd/logs |
{repo, runId} 失败步骤日志的尾部 |
POST /api/dsh-cicd/update |
{repo, tag?}:把该 Release 的 .tgz 下载到 <DSH_HOME>\dsh-plugin-cicd\downloads\ 并装进当前 profile。tag 缺省 = 最新一个非草稿且带 tgz 的 Release |
POST /api/dsh-cicd/restart |
无参数:转发到 dsh-plugin-restart 的重启路由;没挂载则 501 restart-unavailable |
POST /api/dsh-cicd/npm-status |
无参数({force:true} 绕缓存):whoami 的结果 + 每个包在源上的状态。按需拉取,不参与 30 秒轮询——每个仓库一次 HTTPS 加一次 whoami |
POST /api/dsh-cicd/npm-login |
{token}:把 token 合并进用户级 .npmrc 再用 whoami 验证。token 不回显 |
POST /api/dsh-cicd/npm-publish |
{repo, otp?}:用 Host 自己的 pnpm 发布当前版本。otp 是 2FA 一次性密码(6–8 位) |
POST /api/dsh-cicd/bilibili-status |
无参数({force:true} 绕缓存):凭据状态 + 每个仓库的绑定/已播报/失败 + 播报记录文件。不碰 GitHub |
POST /api/dsh-cicd/bilibili-login-start |
无参数:生成 B 站网页登录二维码,返回 { url, key, expiresAt } |
POST /api/dsh-cicd/bilibili-login-poll |
无参数:问一次是否已确认。state ∈ waiting / scanned / succeeded / expired;成功后 Cookie 落盘 |
POST /api/dsh-cicd/bilibili-login-cancel |
无参数:放弃这次登录 |
POST /api/dsh-cicd/bilibili-credential |
{cookie} 或 {sessdata, bili_jct}:验证通过才写入插件自己的凭据文件 |
POST /api/dsh-cicd/bilibili-logout |
无参数:删掉插件自己写的那份凭据(不动 bilibiliCookieFile 指定的外部文件) |
POST /api/dsh-cicd/bilibili-bind |
{repo, bvid, auto?}:绑定/解绑视频;绑定会写一条基线,bvid: '' 即解绑(播报记录保留) |
POST /api/dsh-cicd/bilibili-announce |
{repo, tag?, text?, force?, dryRun?}:组装(dryRun)或发送这一条更新评论。tag 缺省 = 最新的已公开 Release |
一律返回 { ok: true, value } 或 { ok: false, code, message }。
npm-status 的每条仓库记录:{ repo, label, localPath, packageName, version, dirty, privatePackage, state, latest, blockers, canPublish, registryProblem, registry, pageUrl }。state ∈ unregistered / unpublished / published / unknown;blockers 是具名原因(private-package、dirty-tree、not-logged-in、already-published、registry-unreachable、no-checkout、no-version、no-package-name)——"没有按钮"是最没用的一句话,private: true 和"没登录"在屏幕上同样是空白,修法却完全不同。
overview 的每一行多一个 install 块:{ profile, profileDir, profileReadable, packageName, present, spec, kind, installedVersion, latestTag, latestVersion, latestAsset, state }。state ∈ not-installed / no-release / current / update / ahead / differs / checkout——判决在 Host 上做,所以面板和 update 路由不会各说一套。
overview 的每一行还多一个 pipeline 块(null = 这个仓库从没跑过一键发布):{ id, repo, slug, tag, version, headSha, startedAt, updatedAt, status, message, blockedCode, runId, runUrl, runConclusion, skipNpm, stages }。status ∈ running / blocked / done / failed / cancelled;stages 是固定七格 [{ id, status, detail, at }],id 依次是 push / version / dispatch / build / npm / release / announce,status ∈ pending / active / done / skipped / blocked / failed / cancelled。blockedCode 是停住的原因(otp-required、not-logged-in、registry-unreachable、timeout …),面板按它给出下一步。overview 顶层另有 pipelineProblem:记录文件读不出来时一键发布会拒绝启动,原因写在这里。
status 另外报出 profile: { name, dir, readable }:更新写的是哪个 profile,是报出来的而不是假设的——「没装」和「面板看错了 profile」是两句不同的话,只有路径能把它们分开。
为什么「发布」曾经必然失败,以及现在的做法
根因:发布用的是 package.json 里的版本号,而 release.yml 有意拒绝覆盖属于另一个提交的同号 Release(否则公开草稿会创建旧提交上的 tag,tag 与资产从此不一致,而且整个过程静默)。于是只要树往前走了而版本没升,点「发布」触发的运行一定在第一步失败,约 10 秒后红掉;面板却提前宣告「已产出草稿」,真正的原因还藏在两层点击之后。实测有四个失败运行属于这一类(dsh-plugin-restart ×3、dsh-plugin-cicd ×1)。
现在:
- 概览行先给出判决(
releaseCheck)。宿主拿releases[].target_commitish与「这次发布会构建的提交」对比:只有能证明版本已被另一个提交占用时才判blocked(脏工作区、分支名 target、读不到远端提交都算「证明不了」,一律放行)——预检拦下一个本来能成功的发布,比没有预检更糟。行上出现【版本被占用】标记,展开即写明v1.0.0 已属于 48ce81c,而这次发布会构建 e6a1cc1。 dispatch会拒绝注定失败的发布,返回409 version-taken,不再浪费一次运行。- 面板给出解法:被占用时按钮变成【升版本并发布】,确认框里写明「从 1.0.1 升到 1.0.2、提交并推送到 main」——一次点击完成
version-bump+ 发布触发。 - 文案不再替运行结果打包票:触发后说的是「是否真的产出草稿要看这次运行的结果,失败时展开点【日志】」。
version-bump 的拒绝清单(都保证一个字节都没写):工作区有未提交改动(发布构建的是已推送的提交,这些文件会静默缺席于发布包)、分支没有上游、落后于上游、版本号不是 major.minor.patch、清单里出现第二个 version 键。推送失败不回滚:提交是真的,失败通常是 github.com:443 的老问题,所以如实回「本地已提交 X,推送失败」并让你重试推送。
设计取舍
- UI 落在
sidebar.panellist+main,不是浮层:侧边栏拿按钮,主区域拿页面,点开顶开内容而不是遮住它。 - 颜色只取
--dsw-alias-*主题 token,没有自带调色板——面板跟随亮/暗主题,而不是自带一套深色。 - 后台轮询失败不清空、不弹错:合盖的笔记本不会产生一个不是用户造成的错误横幅,只是停止更新。
- HTML/CSS/JS 三处都没有
position: absolute的浮窗:main面板是布局里的一列。
本仓库自身的 CI/CD
它自己也用同一套:ci.yml 每次 push 跑 tests/host-checks.mjs(167 条离线检查,覆盖入口校验/配置夹取/降级路径/发布预检与版本号运算/安装态与更新判决/npm 源与 token 与每条拒绝)、tests/mount-check.mjs(35 条:真挂载、真起 HTTP、真走路由,含更新、转发重启与 npm 三条拒绝路径)、tests/bump-e2e.mjs(29 条:真 git 仓库、真提交、真推送,以及每条拒绝都不留半截改动)与 tests/client-render.mjs(71 条:用桩 React 真渲染面板,证明「发布」与【升版本并发布】确实按判决切换、被拦的原因真的到了屏幕上、一次更新真的 POST 了 /update 并接着问要不要重启、以及一次 npm 推送真的 POST 了 /npm-publish 且未登录时真的能只靠粘贴 token 完成),并真造一个发布包;release.yml 在 v* tag 上构建并上传 Release,对解包后的资产再跑一遍这几套。发布流程见 RELEASING.md。
tests/host-checks.mjs 里另有一半检查需要真实的 gh 与特定的仓库状态,用 DSH_CICD_LIVE=1 打开:
node tests/host-checks.mjs # 离线,CI 跑的就是这个
$env:DSH_CICD_LIVE = 1; node tests/host-checks.mjs
推送通道被阻断时
本机实测:github.com:443 会被间歇阻断(git push 报 Connection was reset 或直接连不上),而 api.github.com:443 一直可达——所以 git 挂了但 gh 一切正常。
scripts/push-via-api.ps1 走 API 造出逐字节相同 SHA 的提交对象再移动 ref,因此本地与远程不会分叉,不需要事后 git reset --hard:
pwsh -File scripts/push-via-api.ps1 -RepoPath 'F:\CodeProj\dsh-plugin-cicd'
它在每一步比对重建出的 blob / tree / commit SHA 与本地提交,任何一处不一致就中止并不移动 ref;成功后连同 refs/remotes/origin/main 一起更新,否则 git status 会显示一个并不存在的「领先」。仓库已经同步时它什么都不做。
已知边界
- 路径按 Windows 书写(
F:\...),ghPath的默认候选也是 Windows 安装位置;其它平台请显式配ghPath。 - 私有仓库要求
gh已登录且 token 有reposcope。 - 只观察 GitHub 上的仓库;本地没有远程的仓库(例如还在本地开发的)不在范围内。
- 更新需要 Host 提供
pluginManager服务(DSH 自带)。没有它时update回501,不会退化成自己起一个dsh子进程去写 profile。 - 重启需要装了
dsh-plugin-restart。发布台不自己杀进程——停哪个、怎么拉起来是那个插件的契约。 - 更新的判定读的是
node_modules/<name>/package.json的版本,不是依赖字符串。link:时它等于本地检出的版本,所以"版本一样"绝不能被当成"装的是发布的那份"——那正是checkout这个状态存在的理由。 - npm 推送复用 Host 自己的包管理器:优先
profileContext.packageManager(DSH 自带的 pnpm),其次DSH_PNPM,再其次打包应用自带的resources/runtime/pnpm,最后才是 PATH 上的pnpm。走到最后一步而机器上没有 pnpm 时,报错会明说ENOENT与它找的是什么。源码里npm-status只做whoami,网络那一侧是 HTTPSfetch(不走HTTP_PROXY,本机实测直连可达)。 - npm 推送没有 provenance,而且用的是长期 token(可写 token 最长 90 天)。真需要
--provenance(构建来源可验证)或不想在机器上放任何 token 的包,应当走 trusted publishing(GitHub Actions OIDC)——那才是 CI 的正路,面板这条路是"在这台机器上推一次"。 - npm 在 2025-11 之后只有 Granular Access Token:Classic token 已全部撤销且不能再创建。可写 token 默认强制 2FA、最长 90 天。任何还写着"建一个 Classic Automation token"的文档都已经过期了。
private: true的包(例如dsh-knowledge-console)面板不会提供推送按钮,这是有意的:那行字段是作者的决定。
License
MIT,见 LICENSE。
No comments yet. Be the first to write one.