Skip to content

Commit a13de2c

Browse files
committed
release cleanup
1 parent 491a505 commit a13de2c

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

scripts/release-check.mjs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env node
2+
//
3+
// release-check -- verify the repository is in a publishable state.
4+
//
5+
// Checks:
6+
// 1. dist/ is up to date with src/ (rebuilds to a temp dir, compares bytes)
7+
// 2. The SHA-384 integrity attributes in www/docs/getting-started.md match
8+
// the current dist files
9+
//
10+
// Exits 0 on success, 1 on any failure. Does NOT mutate dist/ or the docs.
11+
12+
import { createHash } from 'node:crypto'
13+
import { readFileSync, rmSync, mkdtempSync, readdirSync, statSync } from 'node:fs'
14+
import { tmpdir } from 'node:os'
15+
import { join, relative, sep } from 'node:path'
16+
import { fileURLToPath } from 'node:url'
17+
import { execSync } from 'node:child_process'
18+
19+
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
20+
const distDir = join(repoRoot, 'dist')
21+
const docsFile = join(repoRoot, 'www/docs/getting-started.md')
22+
23+
let failures = 0
24+
function fail(msg) { failures++; console.error(' ✗ ' + msg) }
25+
function ok(msg) { console.log(' ✓ ' + msg) }
26+
function section(name) { console.log('\n' + name) }
27+
28+
// -----------------------------------------------------------------------------
29+
// Check 1: dist/ matches a fresh build from src/
30+
// -----------------------------------------------------------------------------
31+
32+
section('Checking dist/ is built from current src/...')
33+
34+
const tmp = mkdtempSync(join(tmpdir(), 'hs-release-check-'))
35+
try {
36+
execSync(`node build.config.mjs`, {
37+
cwd: repoRoot,
38+
env: { ...process.env, HS_OUT_DIR: tmp },
39+
stdio: 'pipe',
40+
})
41+
} catch (e) {
42+
fail('build failed: ' + (e.stderr?.toString() || e.message))
43+
rmSync(tmp, { recursive: true, force: true })
44+
process.exit(1)
45+
}
46+
47+
function walk(dir, base = dir) {
48+
const out = []
49+
for (const name of readdirSync(dir)) {
50+
const full = join(dir, name)
51+
if (statSync(full).isDirectory()) out.push(...walk(full, base))
52+
else out.push(relative(base, full))
53+
}
54+
return out
55+
}
56+
57+
function hashFile(path) {
58+
return createHash('sha256').update(readFileSync(path)).digest('hex')
59+
}
60+
61+
// Only compare files that exist in both trees. Ignore sourcemaps (which embed
62+
// absolute paths) and .br brotli files (which aren't byte-reproducible across
63+
// runs due to compressor internals).
64+
const IGNORE = /\.(map|br|d\.ts)$/
65+
66+
const distFiles = new Set(walk(distDir).filter(f => !IGNORE.test(f)))
67+
const tmpFiles = new Set(walk(tmp).filter(f => !IGNORE.test(f)))
68+
69+
const onlyInDist = [...distFiles].filter(f => !tmpFiles.has(f))
70+
const onlyInTmp = [...tmpFiles].filter(f => !distFiles.has(f))
71+
72+
if (onlyInDist.length) fail(`files in dist/ not produced by build: ${onlyInDist.join(', ')}`)
73+
if (onlyInTmp.length) fail(`build produced files missing from dist/: ${onlyInTmp.join(', ')}`)
74+
75+
const shared = [...distFiles].filter(f => tmpFiles.has(f))
76+
const mismatched = []
77+
for (const f of shared) {
78+
const a = hashFile(join(distDir, f))
79+
const b = hashFile(join(tmp, f))
80+
if (a !== b) mismatched.push(f)
81+
}
82+
83+
rmSync(tmp, { recursive: true, force: true })
84+
85+
if (mismatched.length) {
86+
fail('dist/ is out of date (run `npm run build`):')
87+
for (const f of mismatched) console.error(' ' + f)
88+
} else if (!onlyInDist.length && !onlyInTmp.length) {
89+
ok(`dist/ matches a fresh build (${shared.length} files checked)`)
90+
}
91+
92+
// -----------------------------------------------------------------------------
93+
// Check 2: SRI integrity attributes match the current dist files
94+
// -----------------------------------------------------------------------------
95+
96+
section('Checking SHA-384 integrity attributes...')
97+
98+
function sri(path) {
99+
return 'sha384-' + createHash('sha384').update(readFileSync(path)).digest('base64')
100+
}
101+
102+
const sriMap = {
103+
'_hyperscript.min.js': sri(join(distDir, '_hyperscript.min.js')),
104+
'_hyperscript.js': sri(join(distDir, '_hyperscript.js')),
105+
'_hyperscript.esm.min.js': sri(join(distDir, '_hyperscript.esm.min.js')),
106+
'_hyperscript.esm.js': sri(join(distDir, '_hyperscript.esm.js')),
107+
}
108+
109+
const docs = readFileSync(docsFile, 'utf8')
110+
// Match <script ... src="...filename..." integrity="sha384-..." ...>
111+
// Extract all such tags with their filename + integrity value.
112+
const tagRe = /<script\s+[^>]*?(?:src|href)="([^"]+)"[^>]*?integrity="(sha384-[^"]*)"[^>]*>/g
113+
let m, found = 0
114+
// Match the *longest* candidate first so esm.min.js is checked before min.js.
115+
const sriNames = Object.keys(sriMap).sort((a, b) => b.length - a.length)
116+
while ((m = tagRe.exec(docs)) !== null) {
117+
const url = m[1]
118+
const actualSri = m[2]
119+
const filename = sriNames.find(name => url.endsWith('/' + name) || url.endsWith(name))
120+
if (!filename) continue
121+
found++
122+
const expected = sriMap[filename]
123+
if (actualSri !== expected) {
124+
fail(`${filename}: integrity mismatch`)
125+
console.error(` in docs: ${actualSri}`)
126+
console.error(` expected: ${expected}`)
127+
console.error(` (run \`npm run update-sha\` to fix)`)
128+
}
129+
}
130+
131+
if (found === 0) fail(`no integrity="sha384-..." attributes found in ${relative(repoRoot, docsFile)}`)
132+
else if (failures === 0) ok(`all ${found} integrity attributes match`)
133+
134+
// -----------------------------------------------------------------------------
135+
136+
console.log('')
137+
if (failures > 0) {
138+
console.error(`release-check failed (${failures} issue${failures === 1 ? '' : 's'})`)
139+
process.exit(1)
140+
}
141+
console.log('release-check passed ✓')

0 commit comments

Comments
 (0)