Files
dsh-worktable/01_content/verify-remote.mjs
T

122 lines
8.2 KiB
JavaScript
Raw Normal View History

/**
* dsh-worktable 发布后验证(独立于 release-prep 的发布前验证)。
*
* 远端只读:本脚本只做 GET 下载与本地核对,绝不上传/删除/覆盖/发布任何 GitHub 内容。
* 本地副作用:在临时目录下载/解包/安装(--ignore-scripts)/执行服务端 import 断言,结束后删除临时目录。
* 核对项:latest 固定名资产 + 版本化资产(用包内版本拼出),对每个资产:
* 文件名、SHA-256(必须等于 --expect-sha 提供的发布前验收 SHA,防止"双资产同错")、
* 包结构(package/ 前缀 + 精确 7 文件)、包内版本、npm 安装 + import 断言。
*
* 用法:
* node verify-remote.mjs [--expect-sha <sha256>] [tag]
* # 不传 tag = releases/latest;--expect-sha 来自 release-prep 输出的 dist SHA
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { execPath } from 'node:process'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
function npmCliPath() {
const nodeDir = dirname(execPath)
const c = [join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')].filter((p) => existsSync(p))
return c[0] ?? 'npm'
}
const REPO = 'Aisland-SJL/dsh-worktable'
// 参数严格解析:--expect-sha <64位hex> [tag];缺值/非 hex/重复参数/多余 tag 一律拒绝
const args = process.argv.slice(2)
let EXPECT_SHA = null
let TAG = ''
let sawSha = false
let sawTag = false
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--expect-sha') {
if (sawSha) { console.error('[verify-remote] FAIL: --expect-sha given twice'); process.exit(1) }
const v = args[++i]
if (!v || !/^[0-9a-fA-F]{64}$/.test(v)) { console.error('[verify-remote] FAIL: --expect-sha needs a 64-hex sha256 value, got ' + JSON.stringify(v ?? '')); process.exit(1) }
EXPECT_SHA = v.toLowerCase()
sawSha = true
continue
}
if (a.startsWith('-')) { console.error('[verify-remote] FAIL: unknown option ' + JSON.stringify(a)); process.exit(1) }
if (sawTag) { console.error('[verify-remote] FAIL: more than one tag given (' + JSON.stringify(TAG) + ' and ' + JSON.stringify(a) + ')'); process.exit(1) }
TAG = a
sawTag = true
}
// latest 用特殊路径 /releases/latest/download/<asset>;具体 tag 用 /releases/download/<tag>/<asset>(tag 保留 v 前缀)
const assetBase = TAG === '' ? 'https://github.com/' + REPO + '/releases/latest/download/' : 'https://github.com/' + REPO + '/releases/download/' + TAG + '/'
function httpGet(url, outFile) {
// PowerShell Invoke-WebRequest:走 WinINET 系统代理(本机网络环境需要);-L 行为由 IWR 自动跟随重定向
const ps = "try { Invoke-WebRequest -Uri '" + url.replace(/'/g, "''") + "' -OutFile '" + outFile.replace(/'/g, "''") + "' -UseBasicParsing -TimeoutSec 90 -ErrorAction Stop | Out-Null; exit 0 } catch { Write-Error ($_.Exception.Message); exit 1 }"
const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { stdio: 'pipe', encoding: 'utf8' })
if (r.status !== 0) { console.error((r.stderr || r.stdout || '').slice(0, 500)); return false }
return true
}
const work = join(tmpdir(), 'wt-verify-remote-' + Date.now())
mkdirSync(work, { recursive: true })
// 1. 下载 latest 固定名资产
const fixedUrl = assetBase + 'dsh-worktable.tgz'
const fixedFile = join(work, 'dsh-worktable.tgz')
console.log('[verify-remote] GET ' + fixedUrl)
if (!httpGet(fixedUrl, fixedFile)) { console.error('[verify-remote] FAIL: download fixed-name asset'); process.exit(1) }
// 2. 解包读版本,拼版本化 URL 并下载
const listTar = spawnSync('tar', ['-tzf', fixedFile], { encoding: 'utf8' })
if (listTar.status !== 0) { console.error('[verify-remote] 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 okStruct = entries.length === EXPECTED.length && EXPECTED.every((e) => entries.includes(e))
if (!okStruct) { console.error('[verify-remote] FAIL: structure mismatch: ' + entries.join(',')); process.exit(1) }
console.log('[verify-remote] structure ok (7 files)')
const ext = join(work, 'ext'); mkdirSync(ext, { recursive: true })
const x = spawnSync('tar', ['-xzf', fixedFile, '-C', ext])
if (x.status !== 0) { console.error('[verify-remote] FAIL: tar extract'); process.exit(1) }
const pkg = JSON.parse(readFileSync(join(ext, 'package', 'package.json'), 'utf8'))
const manifest = JSON.parse(readFileSync(join(ext, 'package', 'dsh.plugin.json'), 'utf8'))
if (pkg.version !== manifest.version) { console.error('[verify-remote] FAIL: version mismatch pkg=' + pkg.version + ' manifest=' + manifest.version); process.exit(1) }
if (TAG !== '' && TAG !== 'v' + pkg.version) { console.error('[verify-remote] FAIL: tag ' + TAG + ' != v' + pkg.version + ' (包内版本与请求 tag 不一致)'); process.exit(1) }
console.log('[verify-remote] version = ' + pkg.version)
const verUrl = assetBase + 'dsh-worktable-' + pkg.version + '.tgz'
const verFile = join(work, 'dsh-worktable-' + pkg.version + '.tgz')
console.log('[verify-remote] GET ' + verUrl)
if (!httpGet(verUrl, verFile)) { console.error('[verify-remote] FAIL: download versioned asset'); process.exit(1) }
// 3. SHA 比对(必须先于安装/执行代码):双资产彼此同源 且 都等于发布前验收 SHA
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
const shaFixed = sha(fixedFile)
const shaVer = sha(verFile)
console.log('[verify-remote] fixed-name sha256: ' + shaFixed)
console.log('[verify-remote] versioned sha256: ' + shaVer)
if (shaFixed !== shaVer) { console.error('[verify-remote] FAIL: two assets differ'); process.exit(1) }
if (EXPECT_SHA) {
if (shaFixed !== EXPECT_SHA) { console.error('[verify-remote] FAIL: sha mismatch — got ' + shaFixed + ', expected (release-prep) ' + EXPECT_SHA + ' — 上传的不是本地验收过的包'); process.exit(1) }
console.log('[verify-remote] sha matches release-prep expectation: ' + EXPECT_SHA)
} else {
console.log('[verify-remote] 提示: 未提供 --expect-sha,未做「远端=本地验收包」比对(建议从 release-prep 输出传入)')
}
// 4. 版本化资产安装 + import 断言(独立临时目录;--ignore-scripts 防包脚本副作用;import 会执行服务端模块顶层代码,属功能验证所需)
const inst = join(work, 'inst'); mkdirSync(inst, { recursive: true })
writeFileSync(join(inst, 'package.json'), JSON.stringify({ name: 'wt-verify-test', version: '0.0.0', private: true }))
const ins = spawnSync(process.execPath, [npmCliPath(), 'install', verFile, '--no-audit', '--no-fund', '--ignore-scripts', '--loglevel=error'], { cwd: inst, stdio: 'pipe', encoding: 'utf8' })
if (ins.status !== 0) { console.error('[verify-remote] 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'); if (typeof m.apply !== 'function' || !Array.isArray(m.inject) || m.name !== 'dsh-worktable' || m.HEALTH_PATH !== '/api/worktable/health') process.exit(3); console.log('import ok')"], { cwd: inst, stdio: 'pipe', encoding: 'utf8' })
if (imp.status !== 0) { console.error('[verify-remote] FAIL: import assertion'); console.error(imp.stderr); process.exit(1) }
if (EXPECT_SHA) {
console.log('[verify-remote] PASS: ' + (TAG === '' ? 'latest' : TAG) + ' — 文件名/结构/版本(' + pkg.version + ')/SHA 同源+远端=验收SHA/安装/导入 全部通过(远端只读,本地仅临时目录)——构成发布验收')
} else {
console.log('[verify-remote] INSPECT-ONLY PASS: ' + (TAG === '' ? 'latest' : TAG) + ' — 文件名/结构/版本(' + pkg.version + ')/SHA 同源/安装/导入 通过,但未提供 --expect-sha,未做远端=本地验收包比对,**不构成发布验收**(远端只读,本地仅临时目录)')
}
rmSync(work, { recursive: true, force: true })