170 lines
9.8 KiB
TypeScript
170 lines
9.8 KiB
TypeScript
/** Content and machine identity for reusing a signed helper before installing dependencies. */
|
|
import assert from 'node:assert/strict'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { appendFileSync, lstatSync, readFileSync } from 'node:fs'
|
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
import { isEntry } from '../release/process.ts'
|
|
import { readDesktopPackageSettings } from '../../apps/desktop/scripts/desktop-file-settings.mjs'
|
|
import { resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs'
|
|
|
|
const sourceDirectories = [
|
|
'packages/experimental/cua-macos/native', 'scripts/cua-helper', 'apps/desktop/scripts', 'patches',
|
|
]
|
|
const sourceFiles = new Set([
|
|
'.github/workflows/cua-helper-package.yml', '.github/workflows/release-publish.yml',
|
|
'package.json', 'scripts/package.json', 'apps/package.json', 'apps/desktop/package.json', 'scripts/release/process.ts',
|
|
'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs',
|
|
])
|
|
|
|
function isSourceFile(path: string): boolean {
|
|
return sourceFiles.has(path) || path === '.gitattributes' || path.endsWith('/.gitattributes') || /^(?:(?:apps|apps\/desktop|scripts)\/)?tsconfig[^/]*\.json$/u.test(path)
|
|
|| path.split('/').includes('node_modules')
|
|
|| sourceDirectories.some(directory => path.startsWith(`${directory}/`))
|
|
}
|
|
|
|
function trackedSources(root: string): string[] {
|
|
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
.split('\0').filter(isSourceFile).sort()
|
|
for (const required of ['packages/experimental/cua-macos/native/Package.swift', 'scripts/cua-helper/package.ts',
|
|
'package.json', 'pnpm-lock.yaml']) {
|
|
if (!files.includes(required)) throw new Error(`CUA source: missing tracked build input ${required}`)
|
|
}
|
|
return files
|
|
}
|
|
|
|
function manifestConfiguration(bytes: Buffer, root: boolean): Buffer {
|
|
const manifest: unknown = JSON.parse(bytes.toString('utf8'))
|
|
assert(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'CUA source: invalid package manifest')
|
|
assert(!root || ('packageManager' in manifest && typeof manifest.packageManager === 'string'),
|
|
'CUA source: package.json must declare packageManager')
|
|
return Buffer.from(JSON.stringify(Object.fromEntries(Object.entries(manifest).filter(([name]) => name !== 'version'))))
|
|
}
|
|
|
|
function sourceFile(root: string, file: string): { bytes: Buffer; executable: boolean } {
|
|
const path = join(root, file)
|
|
const stat = lstatSync(path)
|
|
if (!stat.isFile()) throw new Error(`CUA source: build input must be a regular file: ${file}`)
|
|
return { bytes: readFileSync(path), executable: (stat.mode & 0o111) !== 0 }
|
|
}
|
|
|
|
function gitBlobHash(bytes: Buffer): string {
|
|
return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
|
|
}
|
|
|
|
/**
|
|
* Hash tracked build inputs by path, bytes and executable mode; only package manifest version fields is excluded.
|
|
* Missing tracked files and links fail; build outputs and untracked files never enter the digest.
|
|
* @param root - Clean repository root containing all helper build inputs.
|
|
* @returns SHA-256 of sources, resources, workflows, package configuration, patches and locked dependencies.
|
|
*/
|
|
export function cuaHelperSourceDigest(root: string): string {
|
|
const hash = createHash('sha256')
|
|
for (const file of trackedSources(root)) {
|
|
const { bytes, executable } = sourceFile(root, file)
|
|
const input = file === 'package.json' || file.endsWith('/package.json') ? manifestConfiguration(bytes, file === 'package.json') : bytes
|
|
hash.update(`${file}\0`).update(createHash('sha256').update(input).digest()).update(Buffer.from([Number(executable)]))
|
|
}
|
|
return hash.digest('hex')
|
|
}
|
|
|
|
/**
|
|
* Compare a producer's immutable GitHub commit tree with local build inputs independently of artifact metadata.
|
|
* Incomplete trees, unreadable blobs and API failures stop selection; content differences return false.
|
|
* @param root - Consumer repository root, before dependency installation.
|
|
* @param repository - GitHub repository containing the authenticated producer run.
|
|
* @param commit - Producer head SHA returned by GitHub, never an artifact-supplied revision.
|
|
* @param api - Read-only GitHub JSON reader.
|
|
* @returns Resolves to whether selected paths, bytes and executable modes match, excluding package manifest version fields.
|
|
*/
|
|
export async function cuaHelperSourceMatches(root: string, repository: string, commit: string,
|
|
api: (path: string) => Promise<unknown>): Promise<boolean> {
|
|
assert(/^[a-f0-9]{40}$/u.test(commit), 'CUA source: invalid producer commit')
|
|
const response = await api(`repos/${repository}/git/trees/${commit}?recursive=1`)
|
|
assert(response && typeof response === 'object' && 'truncated' in response && response.truncated === false
|
|
&& 'tree' in response && Array.isArray(response.tree), 'CUA source: GitHub returned an incomplete commit tree')
|
|
const tree = new Map<string, { sha: string; mode: string }>()
|
|
const entries: unknown[] = response.tree
|
|
for (const value of entries) {
|
|
assert(value && typeof value === 'object' && 'path' in value && typeof value.path === 'string', 'CUA source: invalid tree entry')
|
|
if (!isSourceFile(value.path) || ('type' in value && value.type === 'tree')) continue
|
|
assert('sha' in value && typeof value.sha === 'string' && /^[a-f0-9]{40}$/u.test(value.sha)
|
|
&& 'mode' in value && typeof value.mode === 'string' && 'type' in value, 'CUA source: invalid source entry')
|
|
if (value.type !== 'blob' || !['100644', '100755'].includes(value.mode)) return false
|
|
assert(!tree.has(value.path), 'CUA source: duplicate source entry')
|
|
tree.set(value.path, { sha: value.sha, mode: value.mode })
|
|
}
|
|
const files = trackedSources(root)
|
|
if (tree.size !== files.length) return false
|
|
for (const file of files) {
|
|
const remote = tree.get(file)
|
|
if (!remote) return false
|
|
const local = sourceFile(root, file)
|
|
if (remote.mode !== (local.executable ? '100755' : '100644')) return false
|
|
if (gitBlobHash(local.bytes) === remote.sha) continue
|
|
const canonical = execFileSync('git', ['hash-object', `--path=${file}`, '--stdin'],
|
|
{ cwd: root, input: local.bytes, encoding: 'utf8' }).trim()
|
|
if (canonical === remote.sha) continue
|
|
if (file !== 'package.json' && !file.endsWith('/package.json')) return false
|
|
const blob = await api(`repos/${repository}/git/blobs/${remote.sha}`)
|
|
assert(blob && typeof blob === 'object' && 'sha' in blob && blob.sha === remote.sha
|
|
&& 'encoding' in blob && blob.encoding === 'base64' && 'content' in blob && typeof blob.content === 'string',
|
|
'CUA source: invalid package manifest blob')
|
|
const bytes = Buffer.from(blob.content, 'base64')
|
|
assert(gitBlobHash(bytes) === remote.sha, 'CUA source: package manifest blob digest mismatch')
|
|
if (!manifestConfiguration(local.bytes, file === 'package.json').equals(manifestConfiguration(bytes, file === 'package.json'))) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Read the signing preset's public identity and certificate location without installing desktop dependencies.
|
|
* @param environment - Actions environment with the machine configuration directory and selected preset.
|
|
* @returns File-owned signing settings and the selected preset; no values are logged.
|
|
*/
|
|
export function readCuaSigningSettings(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const root = environment.DSH_PACKAGING_CONFIG_DIR
|
|
const preset = environment.DSH_PACKAGING_PRESET
|
|
if (!root || !isAbsolute(root) || !['test', 'production'].includes(preset ?? '')) {
|
|
throw new Error('CUA reuse: DSH_PACKAGING_CONFIG_DIR and a test or production preset are required')
|
|
}
|
|
const settings = readDesktopPackageSettings(join(root, 'secrets/macos-signing.env'), () => true)
|
|
return { ...settings, DSH_PACKAGING_PRESET: preset }
|
|
}
|
|
|
|
/**
|
|
* Bind source bytes to the active toolchain, preset and signing certificate container.
|
|
* @param source - Current source digest.
|
|
* @param environment - File-owned signing settings including CSC_LINK and the selected preset.
|
|
* @param run - Synchronous query returning the active compiler, SDK and Python version strings.
|
|
* @returns SHA-256 build key; credential values and certificate bytes are not recorded.
|
|
*/
|
|
export function cuaHelperBuildKey(source: string, environment: NodeJS.ProcessEnv,
|
|
run: (command: string, args: string[]) => string = (command, args) =>
|
|
execFileSync(command, args, { encoding: 'utf8', timeout: 60_000, stdio: 'pipe' }).trim()): string {
|
|
const signing = resolveMacOSSigningEnvironment(environment)
|
|
const certificate = environment.CSC_LINK
|
|
if (!certificate || !isAbsolute(certificate)) throw new Error('CUA reuse: CSC_LINK must identify the signing certificate file')
|
|
const configuration = {
|
|
source, preset: environment.DSH_PACKAGING_PRESET ?? 'local', ...signing,
|
|
certificate: createHash('sha256').update(readFileSync(certificate)).digest('hex'),
|
|
node: process.version,
|
|
swift: run('/usr/bin/xcrun', ['swift', '--version']),
|
|
clang: run('/usr/bin/xcrun', ['clang', '--version']),
|
|
sdk: run('/usr/bin/xcrun', ['--sdk', 'macosx', '--show-sdk-build-version']),
|
|
python: run('/usr/bin/xcrun', ['python3', '--version']),
|
|
}
|
|
return createHash('sha256').update(JSON.stringify(configuration)).digest('hex')
|
|
}
|
|
|
|
if (isEntry(import.meta.url)) {
|
|
const env = process.env
|
|
if (!env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY) throw new Error('CUA build inputs: Actions context required')
|
|
const root = resolve(import.meta.dirname, '../..')
|
|
const sourceDigest = cuaHelperSourceDigest(root)
|
|
const buildKey = cuaHelperBuildKey(sourceDigest, readCuaSigningSettings(env))
|
|
appendFileSync(env.GITHUB_OUTPUT, `build-key=${buildKey}\n`)
|
|
appendFileSync(env.GITHUB_STEP_SUMMARY, `CUA helper 源码摘要:\`${sourceDigest}\`。\n\n`)
|
|
console.log(`CUA helper source digest: ${sourceDigest}`)
|
|
}
|