From 4b384ef639cf4601e2b3259430c28dbc99ddc65c Mon Sep 17 00:00:00 2001 From: Aisland Date: Fri, 4 Sep 2026 00:02:45 +0800 Subject: [PATCH] =?UTF-8?q?chore(release-tooling):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?release-prep.mjs=20=E6=89=93=E5=8C=85+=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E5=90=88=E4=B8=80=E8=84=9A=E6=9C=AC(=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E6=A3=80=E6=9F=A5+=E7=9C=9F=E5=AE=9E=E5=AE=89=E8=A3=85+?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=96=AD=E8=A8=80+=E5=8F=8C=E8=B5=84?= =?UTF-8?q?=E4=BA=A7SHA)=EF=BC=9BAGENTS.md=20=E8=A1=A5=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=BA=AA=E5=BE=8B(=E5=90=8Ctag=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E4=B8=8D=E5=8F=AF=E8=A6=86=E7=9B=96/=E7=BB=93?= =?UTF-8?q?=E6=9E=84+=E5=AE=89=E8=A3=85=E5=8F=8C=E9=AA=8C=E6=94=B6/?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=8F=AF=E8=AF=81=E4=BC=AA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/release-prep.mjs | 121 ++++++++++++++++++++++++++++++++++++ AGENTS.md | 9 +++ 2 files changed, 130 insertions(+) create mode 100644 01_content/release-prep.mjs diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs new file mode 100644 index 0000000..eb8b5f7 --- /dev/null +++ b/01_content/release-prep.mjs @@ -0,0 +1,121 @@ +/** + * dsh-worktable 发布准备脚本(本地构建 + 打包 + 消费方验收 合一) + * + * 职责边界(与 Codex 审阅结论一致): + * - 只负责:版本一致性检查 → 构建 → 语法检查 → npm pack → + * 结构清单断言(package/ 前缀 + 精确 7 文件 + 无源码/无 .map)→ + * 独立临时目录真实安装 + import() 断言 → 双资产从同一已验证包复制 → SHA-256 输出。 + * - 绝不执行:git 操作 / push / tag / gh release / 任何对外发布动作。 + * - 任何断言失败 = 进程以非零码退出,绝不输出"验收通过"。 + * + * 用法(脚本以自身位置解析 01_content,不依赖执行 cwd): + * node release-prep.mjs + * node release-prep.mjs --skip-build # 仅重新验收现有 lib(排障用) + */ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, copyFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { tmpdir } from 'node:os' + +const HERE = dirname(fileURLToPath(import.meta.url)) // 固定 = 01_content,绝不依赖执行 cwd +const SKIP_BUILD = process.argv.includes('--skip-build') +// Windows 下 spawnSync('npm') 有 ENOENT、spawnSync('npm.cmd') 有 EINVAL 问题。 +// 统一通过 node 的 npm-cli.js 调用(node 自带 npm 前缀,跨平台稳定)。 +const { execPath } = await import('node:process') +// node 安装目录下的 npm-cli.js(Win: /node_modules/npm/bin/npm-cli.js);不行则回退 PATH 上的 npm +const nodeDir = dirname(execPath) +const npmCliCandidates = [ + join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), +].filter((p) => existsSync(p)) +const npmCli = npmCliCandidates[0] ?? null +function runNpm(args, opts = {}) { + if (npmCli) return spawnSync(execPath, [npmCli, ...args], { stdio: 'pipe', encoding: 'utf8', ...opts }) + const cmd = process.platform === 'win32' + ? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm ' + args.map((a) => '\"' + a + '\"').join(' ')], { stdio: 'pipe', encoding: 'utf8', ...opts }) + : spawnSync('npm', args, { stdio: 'pipe', encoding: 'utf8', ...opts }) + return cmd +} + +// ---------- 1. 版本一致性(build.mjs 同款 guard,先于一切) ---------- +const pkg = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')) +const manifest = JSON.parse(readFileSync(join(HERE, 'dsh.plugin.json'), 'utf8')) +if (manifest.version !== pkg.version) { + console.error('[release-prep] FAIL: package.json(' + pkg.version + ') != dsh.plugin.json(' + manifest.version + ')') + process.exit(1) +} +const VERSION = pkg.version +console.log('[release-prep] version = ' + VERSION) + +// ---------- 2. 构建 + 语法检查 ---------- +if (!SKIP_BUILD) { + const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit' }) + if (build.status !== 0) { console.error('[release-prep] FAIL: build failed'); process.exit(1) } +} +for (const f of ['lib/index.js', 'lib/client.js']) { + const chk = spawnSync(process.execPath, ['--check', join(HERE, f)], { stdio: 'pipe' }) + if (chk.status !== 0) { console.error('[release-prep] FAIL: node --check ' + f); process.exit(1) } +} +console.log('[release-prep] build + syntax check passed') + +// ---------- 3. npm pack 到临时目录 ---------- +const work = join(tmpdir(), 'wt-release-prep-' + Date.now()) +mkdirSync(join(work, 'pack'), { recursive: true }) +const pack = runNpm(['pack', '--pack-destination', join(work, 'pack')], { cwd: HERE }) +if (pack.status !== 0) { console.error('[release-prep] FAIL: npm pack'); console.error('status=' + String(pack.status) + ' error=' + String(pack.error ?? '') + ' stderr=' + String(pack.stderr ?? '').slice(0, 400)); process.exit(1) } +// npm pack 在 win 上把 notice 输出到 stdout、文件名在末尾一行;也可能输出到 stderr——统一从两者提取 +const allOut = String(pack.stdout ?? '') + String(pack.stderr ?? '') +const packedName = (allOut.match(/^[^\s]+\.tgz$/m) || [])[0]?.trim() +if (!packedName) { console.error('[release-prep] FAIL: cannot determine packed filename'); process.exit(1) } +const tgz = join(work, 'pack', packedName) +console.log('[release-prep] packed: ' + packedName) + +// ---------- 4. 结构清单断言(精确到文件,不含源码/映射) ---------- +const listTar = spawnSync('tar', ['-tzf', tgz], { encoding: 'utf8' }) +if (listTar.status !== 0) { console.error('[release-prep] FAIL: tar -tzf'); process.exit(1) } +const entries = listTar.stdout.split(/\r?\n/).filter(Boolean) +const EXPECTED = [ + 'package/LICENSE', + 'package/README.md', + 'package/cordis.patch.yml', + 'package/dsh.plugin.json', + 'package/lib/client.js', + 'package/lib/index.js', + 'package/package.json', +] +const ok = entries.length === EXPECTED.length && + EXPECTED.every((e) => entries.includes(e)) && + !entries.some((e) => e.startsWith('package/src/')) && + !entries.some((e) => e.endsWith('.map')) +if (!ok) { + console.error('[release-prep] FAIL: package structure mismatch') + console.error(' got: ' + entries.join(', ')) + process.exit(1) +} +console.log('[release-prep] structure check passed (7 files, no src/, no .map)') + +// ---------- 5. 独立临时目录真实安装 + import() 断言 ---------- +const inst = join(work, 'inst') +mkdirSync(inst, { recursive: true }) +writeFileSync(join(inst, 'package.json'), JSON.stringify({ name: 'wt-prep-test', version: '0.0.0', private: true })) +const ins = runNpm(['install', tgz, '--no-audit', '--no-fund', '--loglevel=error'], { cwd: inst }) +if (ins.status !== 0) { console.error('[release-prep] FAIL: npm install'); console.error(ins.stderr); process.exit(1) } +const imp = spawnSync(process.execPath, ['--input-type=module', '-e', "const m = await import('dsh-worktable'); const k = Object.keys(m); if (!['apply','inject','name','HEALTH_PATH'].every(x => k.includes(x))) { console.error('missing exports: ' + k.join(',')); process.exit(3) }"], { cwd: inst, stdio: 'pipe', encoding: 'utf8' }) +if (imp.status !== 0) { console.error('[release-prep] FAIL: import() assertion'); console.error(imp.stderr); process.exit(1) } +console.log('[release-prep] install + import assertion passed') + +// ---------- 6. 双资产从同一已验证包复制 + SHA-256 ---------- +const fixedDir = join(HERE, 'dist') +mkdirSync(fixedDir, { recursive: true }) +const assetFixed = join(fixedDir, 'dsh-worktable.tgz') +const assetVer = join(fixedDir, 'dsh-worktable-' + VERSION + '.tgz') +copyFileSync(tgz, assetFixed) +copyFileSync(tgz, assetVer) +const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex') +console.log('[release-prep] DONE — 双资产同源(同 SHA-256):') +console.log(' dist/dsh-worktable.tgz sha256:' + sha(assetFixed)) +console.log(' dist/dsh-worktable-' + VERSION + '.tgz sha256:' + sha(assetVer)) +console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。请用 gh 手动上传双资产并下载远端比对。') +rmSync(work, { recursive: true, force: true }) diff --git a/AGENTS.md b/AGENTS.md index 3c8a8dc..ab98bf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,15 @@ 「解包 / 复制 / 实跑用户路径」;「构建成功」「bundle 里有字符串」「退出码 0」不算验收。 - **发布前自跑最终产物清单**:干净目录安装、最终包逐文件核对、双资产哈希、关键行逐字 grep —— 先自查再报告,不等外部复审来抓。 +- **发布包验收=结构检查 + 真实安装,两道都保留**:npm pack 标准格式(package/ 前缀 + files + 白名单精确 7 文件,不含 src/、不含 .map)与「独立目录 npm install + import() 断言」 + 缺一不可。教训:v0.3.0 手工 tar 出无前缀平铺包且误卷源码树,「解包核对(文件都在)」发现不了, + 只有 npm install 复现失败;「文件都在」 ≠ 「消费方(npm/dsh plugin add)能装」。 +- **声明可证伪,不许写满**:验收结论只写已验证范围。「通知全覆盖」「等价端到端」这类表述会被 + 反向检查打脸——只写「版本比较逻辑上重新激活」「验证了安装与服务端模块导入」。 +- **同 tag 资产不可覆盖(用户已拍板)**:坏包处理 = 发新版本号 + 旧 Release 正文标注问题, + 默认不替换旧资产;仅经用户明确批准的紧急补救例外,且须:不改名(保持原 URL 名)、 + 用已验证包、不删不盖现有资产、上传后从完整 URL 下载核对、旧 Release 注明补救经过。 ## 边界