Files
dsh-worktable/01_content/release-prep.mjs
T

264 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* dsh-worktable 发布准备脚本(本地构建 + 打包 + 消费方验收 合一)
*
* 职责边界(与 Codex 审阅结论一致):
* - 只负责:版本一致性检查 → 构建 → 语法检查 → npm pack →
* 结构清单断言(package/ 前缀 + 精确 7 文件 + 无源码/无 .map)→
* 独立临时目录真实安装 + import() 断言 → 双资产从同一已验证包复制 → SHA-256 输出。
* - 绝不执行:git 操作 / push / tag / gh release / 任何对外发布动作。
* - 任何断言失败 = 进程以非零码退出,绝不输出"验收通过"。
*
* 用法(脚本以自身位置解析 01_content,不依赖执行 cwd;任何目录都可调用):
* node release-prep.mjs
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, copyFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { gateClientFactory } from './client-factory-gate.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) // 固定 = 01_content,绝不依赖执行 cwd
// 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: <nodeDir>/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)
// ---------- 1b. 身份断言(package/manifest/patch 统一为 dsh-worktable) ----------
const IDENTITY = 'dsh-worktable'
if (pkg.name !== IDENTITY) { console.error('[release-prep] FAIL: package.json name = ' + pkg.name); process.exit(1) }
if (manifest.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json name = ' + manifest.name); process.exit(1) }
if (!manifest.entry || manifest.entry.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json entry.name != ' + IDENTITY); process.exit(1) }
// cordis.patch.yml 严格检查:按缩进层级解析,只接受「- insert: 下的子列表项(id/name=IDENTITY)」结构
const patchRaw = readFileSync(join(HERE, 'cordis.patch.yml'), 'utf8')
const patchLines = patchRaw.split(/\r?\n/)
let patchOk = true
const patchEntries = []
let cur = null
for (const rawLine of patchLines) {
const line = rawLine.trimEnd()
if (line.trim() === '' || line.trim().startsWith('#')) continue
const indent = line.length - line.trimStart().length
const text = line.trim()
if (indent === 0 && text === '- insert:') { cur = {}; patchEntries.push(cur); continue }
if (indent === 0) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml unexpected top-level line: ' + JSON.stringify(text)); break }
if (indent === 4 && text === '- id: ' + IDENTITY) { if (cur && cur.id !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate - id: item (only one plugin entry allowed)'); break }; if (cur) cur.id = IDENTITY; continue }
if (indent === 4 && /^- id:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item id != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
if (indent === 6 && text === 'name: ' + IDENTITY) { if (cur && cur.name !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate name field'); break }; if (cur) cur.name = IDENTITY; continue }
if (indent === 6 && /^name:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item name != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
patchOk = false
console.error('[release-prep] FAIL: cordis.patch.yml unexpected line (indent ' + indent + '): ' + JSON.stringify(text))
break
}
if (patchOk && patchEntries.length !== 1) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml must contain exactly one insert entry, got ' + patchEntries.length) }
if (patchOk && (patchEntries[0].id !== IDENTITY || patchEntries[0].name !== IDENTITY)) {
patchOk = false
console.error('[release-prep] FAIL: cordis.patch.yml insert entry incomplete: ' + JSON.stringify(patchEntries[0]))
}
if (!patchOk) process.exit(1)
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ----------
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.test.mjs', 'request-auth.test.mjs', 'preview-files.test.mjs', 'sandboxed-frame.test.mjs', 'update-check.test.mjs']
.map((file) => join(HERE, '..', '04_test', file))
const compatRun = spawnSync(process.execPath, ['--test', ...compatTests], { cwd: HERE, stdio: 'inherit' })
if (compatRun.status !== 0) { console.error('[release-prep] FAIL: session/input compatibility tests'); process.exit(1) }
console.log('[release-prep] session/input compatibility tests passed (source contracts)')
// 构建必带 cwd: HERE(脚本自身位置=01_content),否则从仓库根调用会输出到根 lib/ 造成旧包
const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit', cwd: HERE })
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) }
// import 断言脚本(写临时文件,避免 -e 多行转义):导出名称 + 类型 + 内容 + 包内版本文件
const assertJs = join(inst, 'assert.mjs')
writeFileSync(assertJs, [
"import { readFileSync } from 'node:fs'",
"const TARGET_VERSION = " + JSON.stringify(VERSION),
"const m = await import('dsh-worktable')",
"const fails = []",
"if (typeof m.apply !== 'function') fails.push('apply not function: ' + typeof m.apply)",
"if (!Array.isArray(m.inject)) fails.push('inject not array')",
"if (!m.inject.includes('webServer')) fails.push('server inject missing webServer')",
"if (!m.inject.includes('sessions')) fails.push('server inject missing sessions')",
"if (m.name !== 'dsh-worktable') fails.push('name mismatch: ' + m.name)",
"if (m.HEALTH_PATH !== '/api/worktable/health') fails.push('HEALTH_PATH mismatch: ' + m.HEALTH_PATH)",
"const pkg = JSON.parse(readFileSync('./node_modules/dsh-worktable/package.json', 'utf8'))",
"if (pkg.version !== TARGET_VERSION) fails.push('installed version mismatch: ' + pkg.version)",
"const idx = readFileSync('./node_modules/dsh-worktable/lib/index.js', 'utf8')",
"const cli = readFileSync('./node_modules/dsh-worktable/lib/client.js', 'utf8')",
"if (!idx.includes(JSON.stringify(TARGET_VERSION))) fails.push('index.js missing target version')",
"if (!cli.includes(JSON.stringify(TARGET_VERSION))) fails.push('client.js missing target version')",
"if (fails.length) { console.error(fails.join(String.fromCharCode(10))); process.exit(3) }",
"console.log('assert ok: apply/inject/name/HEALTH_PATH + versions(' + TARGET_VERSION + ')')",
].join('\n'))
const assertUrl = 'file:///' + assertJs.replace(/\\/g, '/')
const imp = spawnSync(process.execPath, ['--input-type=module', '--eval', 'await import(' + JSON.stringify(assertUrl) + ')'], { 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')
// ---------- 5b. 客户端工厂求值门禁(ModuleLoader 握手真实执行一次;非端到端验收) ----------
// 读「安装目录中的最终产物」lib/client.js(node_modules/dsh-worktable/lib/client.js),而非工作目录——验收最终包
const clientSrc = readFileSync(join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js'), 'utf8')
const CLIENT_EXPECTED_INJECT = ['slots', 'locale', 'sessions', 'conversation', 'workspaces']
try {
gateClientFactory(clientSrc, 'dsh-worktable', CLIENT_EXPECTED_INJECT)
} catch (e) {
console.error('[release-prep] FAIL: client factory gate: ' + (e && e.message ? e.message : String(e)))
process.exit(1)
}
console.log('[release-prep] client factory gate passed (ModuleLoader id + factory evaluation + inject)')
// ---------- 5c. 分栏引擎 DOM 锚点回归(0.1.1/0.1.2 双会话根结构 + 过渡重锚 + 关闭还原) ----------
// 评估「安装目录中的最终产物」lib/client.js(与 5b 同一文件),用假 DOM 驱动 splitStore 全路径
const anchorTest = join(HERE, '..', '04_test', 'anchor-dom.test.mjs')
if (!existsSync(anchorTest)) {
console.error('[release-prep] FAIL: anchor-dom.test.mjs missing')
process.exit(1)
}
const anchorRun = spawnSync(process.execPath, [anchorTest, join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
if (anchorRun.status !== 0) {
console.error('[release-prep] FAIL: anchor-dom.test.mjs')
console.error(anchorRun.stderr || anchorRun.stdout)
process.exit(1)
}
console.log('[release-prep] split anchor dom tests passed (14 scenarios, on installed artifact)')
// Sidebar popup regression evaluates the same installed client, including rail/wide branches.
const sidebarTest = join(HERE, '..', '04_test', 'sidebar-popup.test.mjs')
if (!existsSync(sidebarTest)) { console.error('[release-prep] FAIL: sidebar-popup.test.mjs missing'); process.exit(1) }
const sidebarRun = spawnSync(process.execPath, [sidebarTest, join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
if (sidebarRun.status !== 0) {
console.error('[release-prep] FAIL: sidebar-popup.test.mjs')
console.error(sidebarRun.stderr || sidebarRun.stdout)
process.exit(1)
}
console.log(sidebarRun.stdout.trim())
console.log('[release-prep] sidebar popup tests passed (installed artifact)')
// CSSOM serializes fractional margins; a string-only fake DOM cannot cover this.
const marginRun = spawnSync(process.execPath, [join(HERE, '..', '04_test', 'split-margin.test.mjs'), join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
if (marginRun.status !== 0) {
console.error('[release-prep] FAIL: real Chromium split margin regression')
console.error(marginRun.stderr || marginRun.stdout)
process.exit(1)
}
console.log('[release-prep] real Chromium split margin tests passed (5 scenarios, on installed artifact)')
// ---------- 5d. 服务端数据目录解析回归(loadPkg ↔ resolveDshHomeSafe 循环调用修复) ----------
// 评估「安装目录中的最终产物」lib/index.js;官方路径 / 包不可解析兜底 / ~ 与相对路径展开
const serverHomeTest = join(HERE, '..', '04_test', 'server-home.test.mjs')
const serverSrc = join(inst, 'node_modules', 'dsh-worktable', 'lib', 'index.js')
if (!existsSync(serverHomeTest) || !existsSync(serverSrc)) {
console.error('[release-prep] FAIL: server-home.test.mjs or installed lib/index.js missing')
process.exit(1)
}
const serverRun = spawnSync(process.execPath, [serverHomeTest, serverSrc], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' })
if (serverRun.status !== 0) {
console.error('[release-prep] FAIL: server-home.test.mjs')
console.error(serverRun.stderr || serverRun.stdout)
process.exit(1)
}
console.log('[release-prep] server home resolution tests passed (3 scenarios, on installed artifact)')
// ---------- 6. 双资产从同一已验证包复制 + SHA-256 ----------
const outDir = join(HERE, 'dist', 'v' + VERSION)
mkdirSync(outDir, { recursive: true })
// 输出目录终态约束:只允许固定名 + 版本化两个 tgz;出现未知文件 = 报错,绝不删除
const KNOWN = new Set(['dsh-worktable.tgz', 'dsh-worktable-' + VERSION + '.tgz'])
for (const ent of readdirSync(outDir)) {
if (!KNOWN.has(ent)) {
console.error('[release-prep] FAIL: dist/v' + VERSION + '/ contains unknown file: ' + ent + ' (not deleting; fix manually)')
process.exit(1)
}
}
const assetFixed = join(outDir, 'dsh-worktable.tgz')
const assetVer = join(outDir, 'dsh-worktable-' + VERSION + '.tgz')
copyFileSync(tgz, assetFixed) // 同版本重跑:覆盖这两个已知文件是允许的
copyFileSync(tgz, assetVer)
// 复制后终态断言:目录恰好两个已知文件(无遗漏/无多余),双 SHA 一致
const finalEntries = readdirSync(outDir).sort()
const finalSet = new Set(finalEntries)
if (finalEntries.length !== 2 || !finalSet.has('dsh-worktable.tgz') || !finalSet.has('dsh-worktable-' + VERSION + '.tgz')) {
console.error('[release-prep] FAIL: post-copy final listing mismatch: ' + finalEntries.join(', '))
process.exit(1)
}
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
const shaFixed = sha(assetFixed)
const shaVer = sha(assetVer)
if (shaFixed !== shaVer) { console.error('[release-prep] FAIL: two assets differ after copy'); process.exit(1) }
console.log('[release-prep] DONE — 双资产同源(同 SHA-256),终态目录恰好 2 文件:')
console.log(' dist/v' + VERSION + '/dsh-worktable.tgz sha256:' + shaFixed)
console.log(' dist/v' + VERSION + '/dsh-worktable-' + VERSION + '.tgz sha256:' + shaVer)
console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。上传后请运行 verify-remote.mjs --expect-sha ' + shaFixed + ' 核对远端。')
rmSync(work, { recursive: true, force: true })