fix: include orcad application payload in build identity

This commit is contained in:
m4air
2026-09-28 03:51:34 -07:00
parent 07000430bb
commit efec75754a
4 changed files with 86 additions and 21 deletions
+48 -1
View File
@@ -1,4 +1,6 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { computeLocalOrcadBuildHash } from '../ssh/orcad-local-build-hash'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -172,3 +174,48 @@ describe('computeOrcadBuildHash', () => {
expect(computeOrcadBuildHash('')).toBe('unknown')
})
})
describe('shared orcad build identity', () => {
let directory: string
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orcad-build-identity-'))
})
afterEach(() => rmSync(directory, { recursive: true, force: true }))
it('changes matching host and client hashes when only the application changes', () => {
const entry = join(directory, 'orcad.js')
writeFileSync(entry, 'import("./orcad-app")')
writeFileSync(join(directory, 'orcad-app.js'), 'application-a')
const first = computeLocalOrcadBuildHash(directory)
expect(computeOrcadBuildHash(entry)).toBe(first)
writeFileSync(join(directory, 'orcad-app.js'), 'application-b')
const second = computeLocalOrcadBuildHash(directory)
expect(second).not.toBe(first)
expect(computeOrcadBuildHash(entry)).toBe(second)
writeFileSync(entry, '// launcher changed\nimport("./orcad-app")')
expect(computeLocalOrcadBuildHash(directory)).not.toBe(second)
expect(computeOrcadBuildHash(entry)).toBe(computeLocalOrcadBuildHash(directory))
})
it('preserves the original single-bundle hash for older deployments', () => {
const entry = join(directory, 'orcad.js')
const legacy = 'original single-bundle application'
writeFileSync(entry, legacy)
const expected = createHash('sha256').update(legacy).digest('hex').slice(0, 16)
expect(computeLocalOrcadBuildHash(directory)).toBe(expected)
expect(computeOrcadBuildHash(entry)).toBe(expected)
})
it.each(['missing', 'unreadable'])(
'refuses a %s application instead of hashing only the launcher',
(kind) => {
const entry = join(directory, 'orcad.js')
writeFileSync(entry, 'import("./orcad-app")')
if (kind === 'unreadable') {
mkdirSync(join(directory, 'orcad-app.js'))
}
expect(() => computeLocalOrcadBuildHash(directory)).toThrow()
expect(computeOrcadBuildHash(entry)).toBe('unknown')
}
)
})
+2 -3
View File
@@ -8,8 +8,7 @@
* to cross the process boundary: orcad drives it, the daemon performs it, and the verdict
* travels back over the daemon's socket.
*/
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readOrcadBuildHash } from '../../shared/orcad-build-hash'
import process from 'node:process'
import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health'
import {
@@ -81,7 +80,7 @@ export function computeOrcadBuildHash(entryPath = process.argv[1]): string {
return 'unknown'
}
try {
return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16)
return readOrcadBuildHash(entryPath)
} catch {
return 'unknown'
}
+4 -17
View File
@@ -1,22 +1,9 @@
/**
* The build identity the deploy expects the host to answer with.
*
* It must be computed the same way `computeOrcadBuildHash` computes it on the host —
* sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every
* healthy candidate. Keeping the two in one comment is deliberate: they are one contract
* split across a network, and the version string cannot stand in for it, because
* `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value.
*/
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { readOrcadBuildHash } from '../../shared/orcad-build-hash'
export const ORCAD_BUILD_HASH_LENGTH = 16
export { ORCAD_BUILD_HASH_LENGTH } from '../../shared/orcad-build-hash'
/** The activation gate compares this with the host's hash of the same installed bytes. */
export function computeLocalOrcadBuildHash(localOrcadDir: string): string {
const entry = join(localOrcadDir, 'orcad.js')
return createHash('sha256')
.update(readFileSync(entry))
.digest('hex')
.slice(0, ORCAD_BUILD_HASH_LENGTH)
return readOrcadBuildHash(join(localOrcadDir, 'orcad.js'))
}
+32
View File
@@ -0,0 +1,32 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
export const ORCAD_BUILD_HASH_LENGTH = 16
const APPLICATION_FILENAME = 'orcad-app.js'
/** Keep legacy identities stable; split builds must identify the application as well. */
export function readOrcadBuildHash(entryPath: string): string {
const entry = readFileSync(entryPath)
let application: Buffer
try {
application = readFileSync(join(dirname(entryPath), APPLICATION_FILENAME))
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT' &&
!entry.includes('orcad-app')
) {
return createHash('sha256').update(entry).digest('hex').slice(0, ORCAD_BUILD_HASH_LENGTH)
}
throw error
}
return createHash('sha256')
.update(`orcad-split-build\0${entry.length}\0`)
.update(entry)
.update(`\0${application.length}\0`)
.update(application)
.digest('hex')
.slice(0, ORCAD_BUILD_HASH_LENGTH)
}