name: publish on: workflow_dispatch: {} # 两次手动 dispatch 撞上同一版本时,后一个只是拿版本冲突收尾;真正的浪费是两个 runner 并行 # 跑同一份 check + publish,而且日志顺序会分不清哪一次落到了 registry。 concurrency: group: publish cancel-in-progress: false permissions: contents: read # npm trusted publishing(OIDC)要这一项。这条不能省:pnpm 拿 id-token 去 registry 换发布凭据, # 没有它就换不到。少了它不会安静降级——命令里带着 --provenance 时,签来源证明那一步会硬失败并报 # ERR_PNPM_ID_TOKEN_GITHUB_WORKFLOW_INCORRECT_PERMISSIONS。 # 交换成功时它赢过 NODE_AUTH_TOKEN(pnpm 的 publish_options.rs:"OIDC takes precedence over # a configured static token"),下面那个 secret 只在交换失败时才有用;registry 上该版本的 # _npmUser 记作 GitHub Actions 正是这条路走通的痕迹。 id-token: write # 与 ci.yml 同一枚取值:门禁 certify 的那棵树与发布装的树要由同一个 pnpm 解出。GitHub Actions 没有 # 跨文件的 env,抬版本时与 ci.yml 那份一起动。 env: PNPM_VERSION: 12.11.2 jobs: publish: runs-on: ubuntu-latest # 发布链里有 install + 完整 check ;发布后由 registry 逐步可见。 # 不设上限的话,卡住的那一步会烧满 runner 的上限才被杀,期间没有任何信号。 timeout-minutes: 45 steps: # 发布只允许从默认分支派发:workflow_dispatch 能在任意分支/tag 上触发,而 publish 会把 # 检出时刻的那棵树原样推到 registry——版本号一旦占用就撤不回,只能弃号再发新版。 # 用「失败」而不是 job 级 `if:` 跳过:跳过在结论上是绿的,误点的人只会看到工作流"成功"。 - name: 确认发布只发生在默认分支 env: # 比完整 ref 而不是比 ref_name:`ref_name` 会剥掉 `refs/heads/` / `refs/tags/` # 前缀,于是一枚叫 `main` 的 tag 也能通过这道守卫,把 tag 那棵树推上 registry。 TARGET_REF: ${{ github.ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail if [ "$TARGET_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then echo "::error::发布只能从默认分支(refs/heads/$DEFAULT_BRANCH)派发,当前是 $TARGET_REF。要发版请先把改动合进默认分支再 dispatch。" exit 1 fi # action 按 tag 引用意味着上游动 tag 就等于本仓换了构建器。钉 commit SHA, # tag 版本写在同行注释里,升级时照着改这一行即可。 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # 本仓的 workflow 不用 git 写动词,token 没必要留在 .git/config 里: # 后续步骤会跑仓库自己的 install/构建/测试脚本,也就跑得到依赖里的脚本。 persist-credentials: false - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 with: version: ${{ env.PNPM_VERSION }} - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 registry-url: https://registry.npmjs.org # 与 ci.yml 同一口径:缓存键取自本仓的 pnpm-lock.yaml,省掉这台发布机上每次冷装 # 全套依赖的时间。发布链本来就有 install + 完整 check + prepack,缓存命中的是其中 # 最长的那一段。 cache: pnpm # 版本号一旦占用就撤不回(只能弃号再发新版),而 registry 上已有这一版时本次什么都不会 # 发出去。这一步只花一次 HTTP 请求,挡掉的是后面 venv + Chromium + 完整 check 那几十分钟; # `concurrency` 那一道只挡并行的两次 dispatch,串行重复点(上次成功后又点一次)它看不见。 - name: 确认这个版本还没发过 run: | set -euo pipefail SELFN="$(node -p "require('./package.json').name")" SELFV="$(node -p "require('./package.json').version")" # 包名里的 `@` 与 `/` 都要编码:`@scope/pkg` 直接拼进路径会被 registry 读成两段。 SLUG="$(node -p "encodeURIComponent(process.argv[1])" "$SELFN")" # curl 失败(网络抖动、registry 5xx)不能把这次发布判成失败:真正的占用判据是 # registry 自己在 publish 那一步回的 E409,这一道只是提前几分钟说同一件事。 code=$(curl -sS --max-time 30 --retry 2 -o /dev/null -w "%{http_code}" \ "https://registry.npmjs.org/${SLUG}/${SELFV}") || code=000 case "$code" in 200) echo "::error::${SELFN}@${SELFV} 已存在于 registry,本次没有可发布的内容" exit 1 ;; 404) ;; *) echo "::warning::版本查询回了 ${code},预检无法定论;继续发布,占用由 registry 判" ;; esac # 测试里有一批用例以 existsSync(_env/.venv/bin/python3) 为前提才能真跑起来。那个目录被 # gitignore(它是机器本地状态),所以 CI 上必须自己备一份。缺了它,那批用例会退化成空转: # host.ts 的覆盖率从 100% 掉到 58%,门禁随之变红——而报错只说「覆盖率不达标」,真正的原因 # (环境没备)不出现在任何一行日志里。已实测:本地把 venv 移走,能逐位复现 CI 的数字。 # 是 `fetchers` 不是 `rag`:后者才是本插件要的——`extractionType` 默认 markdown,而 # markdownify 在 rag 里、不在 fetchers 里。装成 fetchers 的话第一次抓取就抛 # ModuleNotFoundError,爬虫那条则因为等一个永远不会出现的产出而变成 20s 超时。 # 浏览器在下面的单独步骤里装完整 Chromium;不用 --only-shell —— Scrapling 的 stealth # 路径在 headless shell 上起不来(实测),而 test:py 的浏览器档需要它。 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.14" # 与 ci.yml 同一口径:依赖文件是 py/requirements-dev.txt,缓存的是它解析出来的 # 那一批轮子(既有检查工具,也有 pip 侧的传递依赖)。 cache: pip cache-dependency-path: py/requirements-dev.txt - name: 备 sidecar 解释器(Python 门禁的前提) run: | python -m venv _env/.venv _env/.venv/bin/python -m pip install --upgrade pip _env/.venv/bin/python -m pip install "scrapling[rag]>=0.5,<0.6" _env/.venv/bin/python -c "import scrapling; print('scrapling', scrapling.__version__)" # Python 侧的静态检查。版本在 py/requirements-dev.txt 里精确钉:规则集随版本漂移, # 不钉的话「升级 ruff」与「新写一处违规」在门禁上长得一模一样。 # 缺这一步时 check:py 会以 "No module named ruff" 硬失败 —— 刻意不跳过。 # 把 venv 顶到 PATH 上。门禁本身不依赖这一行:check:py / test:py 走的是 package.json # 的 `py` 脚本,它在 `_env/.venv/bin/python` 可执行时直接用该绝对路径(与根 # `scripts/lib/py-env.mjs` 同一条规则),所以与 PATH 无关。留它是为了让后续步骤里按名字 # 调用 `python3` 的写法解析到装了 ruff 与 scrapling 的这一支,而不是 hostedtoolcache。 - name: 备 Python 检查工具 run: | _env/.venv/bin/python -m pip install -r py/requirements-dev.txt echo "${GITHUB_WORKSPACE}/_env/.venv/bin" >> "$GITHUB_PATH" - name: 装 Chromium(Python 浏览器档) run: _env/.venv/bin/python -m patchright install --with-deps chromium # 首次发布必须先有 token:Trusted Publisher 只能在 npmjs.com 的**包设置页**配置,而那个页面 # 只有包已发布后才存在(registry 上查不到本包时,配置入口根本无处可点)。因此顺序是: # ① 配 secrets.NPM_TOKEN,发第一版;② 到包设置页登记 Trusted Publisher; # ③ 验证 OIDC 真能跑通后,再删掉 NPM_TOKEN 并吊销旧 automation token。 # ② 到 ③ 之间别拖太久:新建的 trusted publisher 配置有 2 天窗口,期内必须完成第一次 # 成功发布,过期即失效且不可编辑,只能删除重建。 # 这一步只做两件事:token 在场就校验形态、不在场就确认 OIDC 环境在。它**不决定**走哪条—— # 认证优先级写在 permissions 那段注释里:OIDC 在前,token 只兜底。 - name: 确认发布凭据(OIDC 优先,回落静态 token) env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # 与本文件其余 run 块同一口径:没有 `set -e` 时管道里的失败会被吞掉, # 于是 `tr` 拿不到东西时下一步的判据会把「命令坏了」说成「凭据里混了非法字符」。 set -euo pipefail TOKEN="$(printf '%s' "$NPM_TOKEN" | tr -d '\r\n[:space:]')" if [ -n "$TOKEN" ]; then echo "::add-mask::$TOKEN" case "$TOKEN" in npm_*) echo "凭据:secrets.NPM_TOKEN,形态为 npm access token";; ghp_*|github_pat_*) echo "这是 GitHub token——发到公共 npm 需要 npm 站点自己的 token(www.npmjs.com → Access Tokens),GH 的 token 对 npmjs 无效"; exit 1;; *) echo "注意:前缀不是 npm_,请确认它确实是 npm 的 access token";; esac junk=$(printf %s "$TOKEN" | tr -d "A-Za-z0-9_-" | wc -c | tr -d " ") if [ "$junk" != "0" ]; then echo "凭据里混进了非 token 字符(引号、说明文字之类)——请只贴 npm_ 开头那一串,不要带引号" exit 1 fi echo "形态合格。它不决定走哪条:本包 trusted publisher 已登记时 OIDC 优先,这枚 token 只在交换失败时兜底。" # 凭据本身不跨步骤传递:步骤输出会落成 RUNNER_TEMP 下的文件,同 job 后续步骤 # (含 install 与 check 里跑的依赖脚本)都能枚举读到;写进 job 级环境同理。 # 所以这里只做形态校验,预检与发布各自从 secrets 取一次、各自就地清洗。 exit 0 fi # 没有静态 token 时只剩 OIDC 一条路:pnpm 12 内置 trusted publishing # (crates/publish/src/oidc.rs),拿 Actions 的 id-token 去 registry 换凭据。 if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then echo "既没有 secrets.NPM_TOKEN,也没有 OIDC 环境(ACTIONS_ID_TOKEN_REQUEST_URL 为空)——两条路都不可用" echo "补法二选一:① 在仓库 Secrets 里加 NPM_TOKEN;② 保留 permissions: id-token: write," echo "并在 npmjs.com 的 Trusted Publisher 里登记 JayYuen666/dsh-scrapling 与本 workflow" exit 1 fi echo "凭据:OIDC trusted publishing(未配 secrets.NPM_TOKEN)" echo "走法:OIDC(没有静态 token 可回落)。身份由 registry 在交换时核验,本 workflow 不接触任何长期凭据" - name: registry 预检(依赖库必须先公开) id: precheck env: TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail USE_STATIC=false # 响应体临时文件在脚本退出时统一收掉:`set -e` 下 curl 非零退出会直接中止本步, # 逐支 rm 的写法漏掉那条路径,文件就留在 runner 上。 body="" trap 'rm -f "$body"' EXIT PASS_TOKEN="$(printf '%s' "${TOKEN:-}" | tr -d '\r\n[:space:]')" if [ -n "$PASS_TOKEN" ]; then echo "::add-mask::$PASS_TOKEN"; fi # 超时与重试都显式给:不给 --max-time 时一次挂起会占到 job 的 45 分钟上限才被杀; # 不给 --retry 时一次瞬时抖动会落进 `*)` 分支,被判成 registry 侧异常。 CURL_FLAGS=(--silent --show-error --retry 3 --retry-delay 2 --max-time 30) if [ -n "$PASS_TOKEN" ]; then body="$(mktemp)" code=$(curl "${CURL_FLAGS[@]}" -o "$body" -w "%{http_code}" -H "Authorization: Bearer $PASS_TOKEN" "https://registry.npmjs.org/-/whoami") case "$code" in 200) login=$(sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body") USE_STATIC=true # 响应体里是账号名,CI 日志长期留存,只印脱敏摘要。 if [ -n "$login" ]; then echo "whoami: HTTP 200,账号 ${login:0:2}***"; else echo "whoami: HTTP 200(响应体未解析出账号名)"; fi ;; 401|403) echo "::warning::静态 token 被 registry 拒(HTTP $code)——本次不启用它,按 OIDC 发布。这枚 secret 已失效,用完请到 Settings → Secrets 删掉,否则每次发布都留一条本行告警。" PASS_TOKEN="" # 回落要有落点:上面「凭据形态」那一步在 token 在场时提前 exit 0,OIDC 环境是否 # 可用从没被判过。token 在这里失效后如果 OIDC 也不在,真正的失败要等到 pnpm # publish 才以它的原始报错出现,和「凭据失效」混成一件事。同一条判据先问一次。 if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then echo "静态 token 被拒(HTTP $code),且本 job 没有 OIDC 环境(ACTIONS_ID_TOKEN_REQUEST_URL 为空)——两条凭据路都不可用" echo "补法二选一:① 换掉仓库 Secrets 里的 NPM_TOKEN;② 保留 permissions: id-token: write,并在 npmjs.com 的 Trusted Publisher 里登记本仓与本 workflow" exit 1 fi echo "回落条件成立:本 job 有 id-token 环境,按 OIDC 发布" ;; *) echo "whoami 返回 HTTP $code——既不是 200 也不是凭据被拒的 401/403,属 registry 侧异常,先查状态页再发" exit 1 ;; esac else echo "whoami: 跳过(走 OIDC,没有静态 token 可问;身份由 registry 在交换时核验)" fi for pkg in "@jayyuen66%2fdsh-plugin-shared"; do code=$(curl "${CURL_FLAGS[@]}" -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/$pkg") echo "读 $pkg:HTTP $code" if [ "$code" != "200" ]; then # 只有 404 才说明「那一版还没发」;429 与 5xx 是 registry 自己没答上来(重试耗尽后 # 也落在这里),把它们说成「先发依赖库」会把人支到另一条路上去做一件不必要的事。 case "$code" in 404) echo "判读:404 = 该依赖库还没发到 npmjs,先发它;本包是它的消费方,装不上依赖" ;; 429|5??) echo "判读:HTTP $code 是 registry 侧的限流或服务异常(上面的 --retry 已耗尽),先去 status.npmjs.org 看,确认恢复后重跑本次发布,不要去动依赖库" ;; *) echo "判读:HTTP $code 不是 200 也不是 404,本步判不出依赖库的发布状态;先重跑,仍然同样回执再按 registry 侧异常处理" ;; esac exit 1 fi done # 只交结论、不交凭据:发布步据此决定导不导出 NODE_AUTH_TOKEN。 printf 'use_static=%s\n' "$USE_STATIC" >> "$GITHUB_OUTPUT" - run: pnpm install --frozen-lockfile - run: pnpm run check # Python 面不在这里另跑一遍:`check` 已经含 `check:py` 的链条(`py:which` → `face:py` → # `deps:py` → ruff 检查/格式 → mypy → bandit → 覆盖率地板;覆盖率那一步的 `coverage run` # 就是 sidecar 端到端,只跑一次),解释器由该仓 `py` 入口的解析规则选定。强度只写在那一条 # 链里:要放宽或收紧 Python 面就改该仓 `package.json` 的 `check:py`,在 CI 里另起一步就会 # 出现两套说法互相打脸的形状(一步拦、一步只记录)。 # prepack 会重建 host.js / client.js;产物不入库也发得出去。 # 凭据优先级是本文件最容易读反的地方:不是「配了 NPM_TOKEN 就用它、没配才 OIDC」, # 而是 OIDC 在前、token 在后(pnpm 的 publish_options.rs 原话 "OIDC takes precedence over # a configured static token")。trusted publisher 配好时下面那段 export 等于空转, # registry 上该版本的 _npmUser 记成 GitHub Actions 就是这个。 # 注入方式只能是环境变量:setup-node 配了 registry-url 会把 `${NODE_AUTH_TOKEN}` 占位写进 # RUNNER_TEMP/.npmrc 并用 NPM_CONFIG_USERCONFIG 指过去,$HOME/.npmrc 被忽略;真 token 留在 # 环境变量里。别往仓库里的 .npmrc 写明文——pnpm 不展开仓库级 .npmrc 里的 `${...}` 占位 # (npm 会展开,别照 npm 的行为读这一行)。 # 那个 if 不能省:把 NODE_AUTH_TOKEN 设成空串,等于给 .npmrc 里那个 # `${NODE_AUTH_TOKEN}` 占位塞了一个空值。 # 首次发布的前置顺序见上面「确认发布凭据」那一步的注释:先 token 发一版、 # 再去包设置页登记 trusted publisher,此后 OIDC 才可能赢过 token。 # --provenance 取的是另一枚 id-token(audience 为 sigstore),与 npm 交换那枚互相独立, # 所以它不受 trusted publisher 是否已配影响;少了它,产物只剩 npm 自己的签名。 # pnpm 12 的 publish 不接受 --publish-wait-timeout(CI 实测参数解析即退 2), # 因此这里不带它;发布后由 registry 逐步可见。 # 若要改成 staged 发布(CI 只 stage、人工 approve):发布命令换成 `pnpm stage publish`。 # 切换前先到 npmjs 上该包 Settings → Trusted publishing 看一眼 Allowed actions:npm 文档在这里 # 两处说法不一致(网页说 `npm stage publish` 一直允许;API 却把 createStagedPackage 列成一枚 # 可选权限),本仓没有本地判据能判它,所以别假定默认值——stage 第一步就被拒时先回来查这一页。 # approve 两条入口,都要 2FA,走哪条由人决定: # 本机 `pnpm stage approve [--otp <码>]`(多个 id 一批共用一枚 OTP) # 网页 npmjs.com → 该包 → Staged Packages → Approve - name: 发布 env: TOKEN: ${{ secrets.NPM_TOKEN }} USE_STATIC: ${{ steps.precheck.outputs.use_static }} run: | set -euo pipefail # 只有确实拿到可用静态 token 时才导出 NODE_AUTH_TOKEN。导出空串会让 setup-node # 写进 .npmrc 的 ${NODE_AUTH_TOKEN} 占位被填成空值,认证以空凭据发出—— # 那是「配了 OIDC 却没 token」这条正常路径上的失败,不是发布本身的失败。 TOKEN_TIDY="$(printf '%s' "${TOKEN:-}" | tr -d '\r\n[:space:]')" if [ "$USE_STATIC" = "true" ] && [ -n "$TOKEN_TIDY" ]; then echo "::add-mask::$TOKEN_TIDY" export NODE_AUTH_TOKEN="$TOKEN_TIDY" echo "凭据:静态 token 已就位(OIDC 交换成功时 pnpm 仍优先用 OIDC)" else echo "凭据:OIDC trusted publishing" fi pnpm publish --no-git-checks --access public --provenance