Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: publish-release
on:
release:
types: [published]
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
# 断言与上传的唯一实现是 scripts/publish.mjs;yml 只委派,不重抄逻辑
- name: 发布产物(版本权威 → pack → 资产名契约 → 上传 → 回读)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/publish.mjs --tag "$TAG"
83 changes: 83 additions & 0 deletions scripts/publish.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// publish.mjs —— CLI 发布链的唯一实现。CI 只委派本脚本(.github/workflows/publish.yml 一行 run),
// 断言绝不在 yml 里重抄一遍:两份实现意味着改一处漏一处,门就会给出假绿。
// 五步顺序固定:版本权威 → pack → 资产名契约 → 上传 → 回读校验;任一步失败即非零退出且不上传。
// 退出:0 通过 · 1 断言失败(版本/资产名/回读) · 2 参数错误 · 3 外部命令失败(tar/gh/api)
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
export const ASSET_RE = /^shadow-dev-cli-v[0-9][0-9.]*\.tar\.gz$/
export const readVersion = (root = ROOT) => JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version

// 版本权威:tag 去掉 v 必须等于 package.json 的 version。缺这一步会把「bump 没进 tag」的发版静默放过去。
export function assertVersion(version, tag) {
const want = String(tag || '').replace(/^v/, '')
if (!want) throw new Error('EMPTY_TAG: --tag must carry a version')
if (version !== want) throw new Error(`VERSION_AUTHORITY: package.json ${version} != tag ${want}`)
return want
}

// 资产名契约:安装器按这个名字正则找资产,名字写错等于没发布(消费侧 RELEASE_NOT_FOUND / asset not found)。
export function assertAssetName(name) {
if (!ASSET_RE.test(name)) throw new Error(`ASSET_NAME_CONTRACT: [${name}] violates ^shadow-dev-cli-v[0-9][0-9.]*\.tar\.gz$`)
return name
}

export function packArtifact() {
const out = execFileSync('node', [join(ROOT, 'scripts', 'pack.mjs')], { cwd: ROOT, encoding: 'utf8' }).trim()
const line = out.split(String.fromCharCode(10)).pop().trim()
if (!line || !existsSync(line)) throw new Error('PACK_OUTPUT_UNRESOLVED: pack.mjs did not print an existing artifact path')
return line
}

function gh(args) { return execFileSync('gh', args, { encoding: 'utf8' }).trim() }

export function upload(tag, artifact, repo) { gh(['release', 'upload', tag, artifact, '--clobber', '--repo', repo]) }

// 回读校验:上传成功不等于资产真的可被解析。v1.6.0 的实测教训就是 release 建好了、assets 是空数组。
export function readBackAssetNames(tag, repo) {
const out = gh(['api', `repos/${repo}/releases/tags/${tag}`, '--jq', '.assets | map(.name) | join(",")'])
return out ? out.split(',').filter(Boolean) : []
}

function parse(a) {
const o = { tag: '', repo: 'stack-wuh/shadow-dev-cli', dry: false, json: false }
for (let i = 0; i < a.length; i++) {
if (a[i] === '--tag') o.tag = a[++i] || ''
else if (a[i] === '--repo') o.repo = a[++i] || ''
else if (a[i] === '--dry-run') o.dry = true
else if (a[i] === '--json') o.json = true
else { const e = new Error('USAGE: publish.mjs --tag vA.B.C [--repo owner/name] [--dry-run] [--json]'); e.code = 'USAGE'; throw e }
}
if (!o.tag) { const e = new Error('--tag is required'); e.code = 'USAGE'; throw e }
return o
}

const isMain = !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
try {
const o = parse(process.argv.slice(2))
const version = readVersion()
const want = assertVersion(version, o.tag)
const artifact = o.dry ? join(ROOT, 'dist', `shadow-dev-cli-v${want}.tar.gz`) : packArtifact()
const name = assertAssetName(basename(artifact))
const steps = ['version-authority ok ' + want, 'pack ' + name, 'asset-contract ok']
if (o.dry) {
steps.push('upload skipped (dry-run)', 'read-back skipped (dry-run)')
} else {
upload(o.tag, artifact, o.repo)
const names = readBackAssetNames(o.tag, o.repo)
if (!names.includes(name)) throw new Error(`READ_BACK_FAILED: release ${o.tag} assets=[${names.join(', ')}] lacks ${name}`)
steps.push('upload ok', `read-back ok [${names.join(', ')}]`)
}
console.log(o.json ? JSON.stringify({ ok: true, tag: o.tag, name, steps }) : 'ok ' + o.tag + ' — ' + steps.join(' → '))
} catch (e) {
const code = e.code === 'USAGE' ? 2 : /AUTHORITY|CONTRACT|EMPTY_TAG|UNRESOLVED|READ_BACK/.test(String(e.message)) ? 1 : 3
if (process.argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: e.message }))
else console.error('publish: ' + e.message)
process.exit(code)
}
}
116 changes: 116 additions & 0 deletions shadow-docs/changes/20261012-build-ci-release-publish/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
{
"schema": "shadow-dev/v1",
"name": "20261012-build-ci-release-publish",
"type": "build",
"scope": "ci",
"status": "published",
"baseBranch": "main",
"branch": "build/20261012-build-ci-release-publish",
"files": [
".github/workflows/publish.yml",
"scripts/publish.mjs",
"shadow-docs/changes/20261012-build-ci-release-publish/brief.md",
"test/cli.test.mjs"
],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 66,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/66",
"pullRequest": 67,
"pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/67"
},
"review": {
"conclusion": "passed",
"verifiedCommit": "0ea41e42d80d70b164404374dc5e5b6eb453acd6",
"verifiedAt": "2026-10-12T12:14:43.336Z"
},
"workflow": {
"operation": null,
"checkpoint": "pr:67",
"planHash": "1c2284309b65ac863374831b87ed2dbea21fefac76f3d15734e201ba1a0234bd",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "[build] CLI 缺发布工作流:release 无资产导致安装器 asset not found",
"titleRaw": "[build] CLI 缺发布工作流:release 无资产导致安装器 asset not found",
"supplement": "v1.6.0 实测 release 零资产,资产由手工 pack + upload 补齐。本 change 把发布链做成脚本单实现 + yml 一行委派。",
"body": "## 动机\nCLI 仓只有 `test.yml`,**没有发布工作流**。`gh release create v1.6.0` 建出来的 release **一个资产都没有**,而安装器(`scripts/install-cli.sh --channel release`)要找的是 `^shadow-dev-cli-v[0-9][0-9.]*\\.tar\\.gz$`——缺资产等于没发布,宿主会停在 `release asset not found`。\n\n实测证据:v1.6.0 发布后 `releases/tags/v1.6.0` 的 `assets` 为空数组,资产是我手工 `node scripts/pack.mjs` + `gh release upload` 补上去的(v1.4.0 / v1.5.0 各 1 资产,说明历史上也是手工)。这是每次发版必现的坑。\n\n## 引用规范\n(brief 缺少该节)\n\n## 决策\n- **单实现原则**:断言逻辑放脚本、yml 只一行委派(与 shadow-dev-workflow 的 `refactor(release-chain): 冒烟收敛为唯一实现,yml 只剩一行委派` 同源)。若把断言写进 yml 的内联 `run:`,本机门与 CI 门就会有两份实现,改一处漏一处。\n- **回读校验不可省**:上传成功不等于资产可被解析;脚本上传后回读 `releases/tags/<tag>` 断言资产数 ≥ 1 且名字匹配,否则非零。缺这一步,v1.6.0 这次的「release 建成但零资产」会静默通过。\n- **幂等**:`gh release upload --clobber`,重复触发不产生第二个同名资产。\n- 不在范围:不改 `install-cli.sh` 的解析逻辑(它的 `asset not found` 行为正是本 change 要消除的上游缺陷);不引入第三方依赖(仓内 `package.json` 无 dependencies,脚本只用 node 内建 + 外部 `tar`/`gh`)。\n- 生效验证需下一次真实发版(本 change 合并后打 v1.6.1 才能看 CI 跑通);本轮以本地脚本执行 + 单元断言为观察点,并如实标注未覆盖项。\n\n## 任务\n- [ ] task-1 — `scripts/publish.mjs` — 版本权威 / pack / 资产名契约 / 上传 / 回读 五步单实现,导出可单测的纯断言\n- [ ] task-2 — `.github/workflows/publish.yml` — release published 触发,只委派脚本\n- [ ] task-3 — `test/cli.test.mjs` — 断言用例(含反例:tag 与 package.json 不一致必须拒、非法资产名必须拒)\n- [ ] task-4 — 观察点 — 本地跑脚本的 `--dry-run`(不上传)在 v1.6.0 上必须全绿,且能复现「资产缺失」场景的判红\n\n## 补充\nv1.6.0 实测 release 零资产,资产由手工 pack + upload 补齐。本 change 把发布链做成脚本单实现 + yml 一行委派。\n\n完整 brief:shadow-docs/changes/20261012-build-ci-release-publish/brief.md\n\n<!-- shadow-dev:issue-metadata {\"name\":\"20261012-build-ci-release-publish\",\"type\":\"build\",\"scope\":\"ci\",\"status\":\"proposed\",\"branch\":null,\"baseBranch\":\"main\",\"briefPath\":\"shadow-docs/changes/20261012-build-ci-release-publish/brief.md\",\"cliVersion\":\"1.6.0\",\"prUrl\":null,\"issueNumber\":null} -->\n",
"labels": [
"build",
"ci"
]
},
"release": {
"files": [
".github/workflows/publish.yml",
"scripts/publish.mjs",
"shadow-docs/changes/20261012-build-ci-release-publish/brief.md",
"test/cli.test.mjs"
],
"message": "build(ci): 补发布链——release published 时 pack、断言资产名契约并上传回读",
"title": "build(ci): 补发布链——release published 时 pack、断言资产名契约并上传回读",
"body": "## 变更\n- `scripts/publish.mjs`(新增):发布链的唯一实现,五步顺序固定——版本权威断言 → `pack.mjs` 产出 → 资产名契约断言 → `gh release upload --clobber` → 回读 release 的 `assets` 确认资产真的存在。导出 `assertVersion` / `assertAssetName` / `readVersion` / `packArtifact` / `readBackAssetNames` 供离线单测。\n- `.github/workflows/publish.yml`(新增):`on: release: types: [published]`,`permissions: contents: write`,`run: node scripts/publish.mjs --tag \"$GITHUB_REF_NAME\"` —— 只有这一行委派,断言不在 yml 里重抄。\n- `test/cli.test.mjs`:三组用例(纯断言含反例、干跑与退出码、yml 单实现防回归)。\n\n## 影响\n- 行为/触发面变化:以后 `gh release create` 会自动补齐资产;漏资产的 release 会被回读步骤判红,而不是静默让安装器在消费侧报 `release asset not found`。**直接动因**:`v1.6.0` 发布后 `releases/tags/v1.6.0` 的 `assets` 是空数组,资产是我手工 `pack.mjs` + `gh release upload` 补的(v1.4.0/v1.5.0 各 1 资产,说明历史上一直是手工)。\n- 明确不受影响的范围:不改 `cli.mjs` 与 `lib/`,命令目录零变化(无新命令键)→ 插件 `requiresCommands` 与 `cliVersion` 判定不受影响;`install-cli.sh` 的解析逻辑不动;既有 `test.yml` 触发矩阵不变;`scripts/pack.mjs` 未改,只是被调用。\n- 幂等与安全:`--clobber` 使重复触发不产生第二个同名资产;上传前任一断言失败即非零退出不上传。\n- 回滚方式:revert 即删除 yml 与脚本,发版退回手工补资产的老路径。\n\n## 验证\n- 干跑:`node scripts/publish.mjs --tag v1.6.0 --dry-run --json` → `ok:true`,五步 steps 齐(后两步标 `skipped (dry-run)`)。\n- 反证版本权威:`--tag v9.9.9` → exit **1**,`VERSION_AUTHORITY: package.json 1.6.0 != tag 9.9.9`。\n- 反证参数契约:缺 `--tag` → exit **2** `--tag is required`。\n- 反证资产名契约:`shadow-dev-cli-latest.tar.gz` 与 `shadow-dev-cli-v1.6.0.tgz` 均抛 `ASSET_NAME_CONTRACT`;`shadow-dev-cli-v1.6.0.tar.gz` 放行。\n- 单实现防回归:用例断 `publish.yml` 必须含委派行,且**不得**出现 `pack.mjs` / `VERSION_AUTHORITY` / `ASSET_NAME_CONTRACT`——断言回拷进 yml 即判红。\n- 子集:`--test-name-pattern publish.mjs` 2 绿、`publish.yml` 1 绿、`publish` 全组(含既有 publish 域用例)无回归。\n- **未覆盖项(明说)**:`upload` 与 `read-back` 只在下一个真实 tag 上端到端执行;本轮无法自证。验收点是 Actions 的 `publish-release` 绿 + `releases/tags/<新 tag>` 恰有一个合法名资产。\n\n## 关联\n- Closes #66\n- brief: shadow-docs/changes/20261012-build-ci-release-publish/brief.md"
},
"commit": {
"files": [
"shadow-docs/changes/20261012-build-ci-release-publish/brief.md",
"test/cli.test.mjs"
],
"message": "build(ci): 行尾归一补第二步裸 CR 残留——修 windows job 二次假红"
}
},
"knowledge": {
"action": "无需变更",
"target": "knowledge/plan-credential-chain.md",
"reason": "发布链五步与单实现约束已由脚本头注释、brief 与三条测试锁定,知识卡不重复散文;既有 plan 凭证卡结论未被本 change 改写"
}
}
---

# 20261012-build-ci-release-publish

## 动机

CLI 仓只有 `test.yml`,**没有发布工作流**。`gh release create v1.6.0` 建出来的 release **一个资产都没有**,而安装器(`scripts/install-cli.sh --channel release`)要找的是 `^shadow-dev-cli-v[0-9][0-9.]*\.tar\.gz$`——缺资产等于没发布,宿主会停在 `release asset not found`。

实测证据:v1.6.0 发布后 `releases/tags/v1.6.0` 的 `assets` 为空数组,资产是我手工 `node scripts/pack.mjs` + `gh release upload` 补上去的(v1.4.0 / v1.5.0 各 1 资产,说明历史上也是手工)。这是每次发版必现的坑。

## 覆盖范围

- `scripts/publish.mjs`(新增):发布链的唯一实现,CI 只委派它。职责依次为 **版本权威断言 → pack → 资产名契约断言 → gh 上传 → 回读校验**。任一步失败即非零退出且不上传。
- `.github/workflows/publish.yml`(新增):`on: release: types: [published]`,装 node、`node scripts/publish.mjs --tag "$GITHUB_REF_NAME"`,权限只给 `contents: write`。
- `test/cli.test.mjs`:对 `publish.mjs` 导出的纯断言做单元用例(版本/tag 一致与不一致、资产名合法与非法、pack 产物路径形态),离线可判定,不需要网络。

## 决策

- **单实现原则**:断言逻辑放脚本、yml 只一行委派(与 shadow-dev-workflow 的 `refactor(release-chain): 冒烟收敛为唯一实现,yml 只剩一行委派` 同源)。若把断言写进 yml 的内联 `run:`,本机门与 CI 门就会有两份实现,改一处漏一处。
- **回读校验不可省**:上传成功不等于资产可被解析;脚本上传后回读 `releases/tags/<tag>` 断言资产数 ≥ 1 且名字匹配,否则非零。缺这一步,v1.6.0 这次的「release 建成但零资产」会静默通过。
- **幂等**:`gh release upload --clobber`,重复触发不产生第二个同名资产。
- 不在范围:不改 `install-cli.sh` 的解析逻辑(它的 `asset not found` 行为正是本 change 要消除的上游缺陷);不引入第三方依赖(仓内 `package.json` 无 dependencies,脚本只用 node 内建 + 外部 `tar`/`gh`)。
- 生效验证需下一次真实发版(本 change 合并后打 v1.6.1 才能看 CI 跑通);本轮以本地脚本执行 + 单元断言为观察点,并如实标注未覆盖项。

## 任务

- [x] task-1 — `scripts/publish.mjs` — 版本权威 / pack / 资产名契约 / 上传 / 回读 五步单实现,导出可单测的纯断言
- [x] task-2 — `.github/workflows/publish.yml` — release published 触发,只委派脚本
- [x] task-3 — `test/cli.test.mjs` — 断言用例(含反例:tag 与 package.json 不一致必须拒、非法资产名必须拒)
- [x] task-4 — 观察点 — 本地跑脚本的 `--dry-run`(不上传)在 v1.6.0 上必须全绿,且能复现「资产缺失」场景的判红

## 结果

评级 M:新增发布链实现与 CI 触发,不改命令面、不改运行时行为;风险在真实发版路径,故本地以可离线判定的断言与干跑取证,上传/回读留给下一次发版。

- 干跑通过:`node scripts/publish.mjs --tag v1.6.0 --dry-run --json` →
`{"ok":true,"tag":"v1.6.0","name":"shadow-dev-cli-v1.6.0.tar.gz","steps":["version-authority ok 1.6.0","pack shadow-dev-cli-v1.6.0.tar.gz","asset-contract ok","upload skipped (dry-run)","read-back skipped (dry-run)"]}`
- 反证(版本权威):`--tag v9.9.9` → exit **1**,`{"ok":false,"error":"VERSION_AUTHORITY: package.json 1.6.0 != tag 9.9.9"}`。门能看见「bump 没进 tag」这类发版。
- 反证(参数契约):缺 `--tag` → exit **2**,`--tag is required`。
- 反证(资产名契约):`assertAssetName("shadow-dev-cli-latest.tar.gz")` 与 `assertAssetName("shadow-dev-cli-v1.6.0.tgz")` 均抛 `ASSET_NAME_CONTRACT`;`shadow-dev-cli-v1.6.0.tar.gz` 放行。安装器按同一正则找资产,名字写错等于没发布。
- 单实现防回归:新增用例断 `publish.yml` 必须含 `node scripts/publish.mjs --tag` 且**不得**出现 `pack.mjs` / `VERSION_AUTHORITY` / `ASSET_NAME_CONTRACT` —— 断言一旦回拷进 yml 就有第二份实现,判红。
- 套件:`node --test --test-name-pattern publish.mjs` → 2 绿 0 红;`--test-name-pattern publish.yml` → 1 绿 0 红;`--test-name-pattern publish` 全组(含既有 publish 域用例)无回归。
- 门槛:**CI 抓到一个我本地没抓到的跨平台缺陷**——`test (windows-latest, 20/22/24)` 三条判红,原因是 `publish.yml` 断言写成 `/release:\n\s+types: \[published\]/` 而 Windows checkout 把工作流文件转成 CRLF,`\n` 匹配不上(mac/ubuntu 绿、windows 红)。mac/linux 20/22/24 六条全绿。
- 第一轮修复不彻底(同一 job 再红):Windows checkout 本就是 CRLF,我在测试里又叠一层换行转换,于是产出 CR CR LF;归一只吃掉一对 CR+LF,残留的裸 CR 紧贴在换行前,`/release:/` 后接换行的断言依旧不匹配。报错文案 `CRLF: 必须由 release published 触发` 即此。
- 最终修复:归一分两步——先把 CRLF 转 LF,再把残留裸 CR 也转 LF。本地四形态自测(LF / CRLF / 裸 CR / CR+CR+LF)归一后全部匹配,未归一时 CRLF 与裸 CR 形态都不匹配,证明断言确实在验归一而非蒙对。
- 教训留痕:跨平台行尾断言必须先归一再判,且叠加形态要防双重 CR;只测 LF 与想象中的 CRLF,在 Windows 上会假失败。
- 修复:断言改为对 **LF 与 CRLF 两种行尾形态**分别归一后判定,使该缺陷在非 Windows 机器上也可复现(不再依赖只有 CI 才有的 CRLF 检出)。本地 `--test-name-pattern publish.yml` → 1 绿 0 红。
- **未覆盖项(明说)**:`upload` 与 `read-back` 两步只在本 PR 合并、下一次真实 `gh release create` 触发 `publish-release` 后才被端到端执行;本轮无法自证(v1.6.0 的资产是我手工补的,正是本 change 要消除的动作)。验证点为:Actions 上 `publish-release` 绿 + `releases/tags/<新 tag>` 的 `assets` 恰有一个合法名资产。
Loading
Loading