Files
orca/config/scripts/release-javascript-parity.mjs
T
Neil 83cf7cf5e2 perf(ci): compile release JavaScript once for all packaging hosts (#25828)
* perf(ci): share release JavaScript across packaging hosts

* fix(ci): verify the projected web entry in release archives

* fix(ci): use the Windows system archive tool for release bundles

* test(ci): retain stylesheet evidence in release build comparisons

* test(ci): verify release parity across native color rounding

* test(ci): normalize manifest asset references without changing import order

* test(ci): compare portable outputs across Windows text and color formatting

* test(ci): preserve module identity across dependent asset hashes

* fix(ci): keep SVG build inputs identical across release hosts

* fix(ci): stabilize compiler inputs and projected web bindings

* fix(ci): retain vendor minification in projected web output

* test(ci): normalize platform-specific pnpm manifest source paths

* fix(packaging): exclude shared build staging from application files

* test: align thinking-state fixtures with the current source shape

* test(mobile): reuse message fixtures within the line limit
2026-10-06 13:22:04 -07:00

166 lines
6.1 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { basename, join } from 'node:path'
const ASSET_PATH = /^(?:renderer|web)\/assets\/(.+)-[\w-]{8}\.(js|css|svg)$/
const TEXT_FILE = /\.(?:js|css|html|json|svg)$/
const NATIVE_COLOR = /color\(display-p3 ([^)]+)\)|(?<![\w-])lab\(([^)]+)\)/g
function createAssetReferenceNormalizer(replacements) {
const references = new RegExp(
[...replacements.keys()].map((name) => name.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&')).join('|') ||
'(?!)',
'g'
)
return (content) => content.replace(references, (name) => replacements.get(name) ?? name)
}
export function normalizeManifestSourcePaths(value) {
if (typeof value === 'string') {
return value.replace(/node_modules\/\.pnpm\/[^/]+\/node_modules\//g, 'node_modules/')
}
if (Array.isArray(value)) {
return value.map(normalizeManifestSourcePaths)
}
if (!value || typeof value !== 'object') {
return value
}
const entries = Object.entries(value)
.map(([key, child]) => [normalizeManifestSourcePaths(key), normalizeManifestSourcePaths(child)])
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
assert.equal(
new Set(entries.map(([key]) => key)).size,
entries.length,
'Ambiguous manifest source paths'
)
return Object.fromEntries(entries)
}
export function annotateJavascriptParityFiles(root, files) {
const names = new Map()
for (const file of files) {
const match = file.path.match(ASSET_PATH)
if (match) {
const stem = `${match[1]}.${match[2]}`
const entries = names.get(stem) ?? new Set()
entries.add(basename(file.path))
names.set(stem, entries)
}
}
const replacements = new Map(
[...names]
.filter(([, entries]) => entries.size === 1)
.map(([stem, entries]) => [[...entries][0], stem])
)
const contents = new Map(
files
.filter((file) => TEXT_FILE.test(file.path))
.map((file) => [
file.path,
readFileSync(join(root, file.path), 'utf8').replaceAll('\r\n', '\n')
])
)
const normalizeIdentity = createAssetReferenceNormalizer(
new Map([...names].flatMap(([stem, entries]) => [...entries].map((name) => [name, stem])))
)
const ambiguous = []
for (const [stem, entries] of names) {
if (entries.size < 2) {
continue
}
for (const name of entries) {
const file = files.find((file) => ASSET_PATH.test(file.path) && basename(file.path) === name)
// Dependency hashes identify the module; final checks preserve every resolved reference.
const signature = createHash('sha256')
.update(normalizeIdentity(contents.get(file.path)))
.digest('hex')
const extension = stem.slice(stem.lastIndexOf('.'))
ambiguous.push([name, `${stem.slice(0, -extension.length)}-${signature}${extension}`])
}
}
const aliases = new Map([...replacements, ...ambiguous])
const normalizeReferences = createAssetReferenceNormalizer(aliases)
return files.map((file) => {
const name = basename(file.path)
const comparablePath = aliases.has(name)
? file.path.slice(0, -name.length) + aliases.get(name)
: file.path
if (!contents.has(file.path)) {
return { ...file, comparablePath, comparableSha256: file.sha256 }
}
let content = normalizeReferences(contents.get(file.path))
const manifest = file.path === 'renderer/.vite/manifest.json'
if (manifest) {
content = JSON.stringify(normalizeManifestSourcePaths(JSON.parse(content)))
}
return {
...file,
comparablePath,
comparableSha256: createHash('sha256').update(content).digest('hex'),
...(file.path.endsWith('.css') ? { css: content } : {}),
...(manifest ? { manifest: content } : {}),
...(/(?:\/assets\/(?:App|Settings|ghostty|shell-icons)-.*\.js|\/[^/]+\.html)$/.test(file.path)
? { text: content }
: {})
}
})
}
export function equivalentStylesheets(before, after) {
const beforeColors = [...before.matchAll(NATIVE_COLOR)]
const afterColors = [...after.matchAll(NATIVE_COLOR)]
const marker = (color) => (color.startsWith('lab(') ? 'NATIVE_LAB' : 'NATIVE_P3')
if (before.replace(NATIVE_COLOR, marker) !== after.replace(NATIVE_COLOR, marker)) {
return false
}
if (beforeColors.length !== afterColors.length) {
return false
}
return beforeColors.every((color, index) => {
const left = (color[1] ?? color[2]).split(/\s+/)
const right = (afterColors[index][1] ?? afterColors[index][2]).split(/\s+/)
const tolerance = color[1] ? 0.00000101 : 0.00010001
return (
left.length === right.length &&
left.every((value, channel) => {
if (value === right[channel]) {
return true
}
// Native CSS color conversion differs by one printed decimal unit across hosts.
return (
/^-?(?:\d*\.)?\d+%?$/.test(value) &&
/^-?(?:\d*\.)?\d+%?$/.test(right[channel]) &&
value.endsWith('%') === right[channel].endsWith('%') &&
Math.abs(Number.parseFloat(value) - Number.parseFloat(right[channel])) <= tolerance
)
})
)
})
}
export function compareJavascriptParityFiles(before, after) {
const reference = new Map(before.map((file) => [file.comparablePath, file]))
const candidate = new Map(after.map((file) => [file.comparablePath, file]))
assert.equal(reference.size, before.length, 'Ambiguous baseline output paths')
assert.equal(candidate.size, after.length, 'Ambiguous shared output paths')
const changed = [...new Set([...reference.keys(), ...candidate.keys()])].filter((path) => {
const left = reference.get(path)
const right = candidate.get(path)
if (!left || !right) {
return true
}
if (left.comparableSha256 === right.comparableSha256) {
return false
}
if (left.manifest && right.manifest) {
return (
JSON.stringify(normalizeManifestSourcePaths(JSON.parse(left.manifest))) !==
JSON.stringify(normalizeManifestSourcePaths(JSON.parse(right.manifest)))
)
}
return !left.css || !right.css || !equivalentStylesheets(left.css, right.css)
})
return changed
}