hololake-system-architecture/product-source/hololake-platform/scripts/internal-release-provenance.mjs

174 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const COMMIT_PATTERN = /^[0-9a-f]{40}$/u
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/u
const ARCHITECTURE_VERSION_PATTERN = /^\d{4}-\d{2}-\d{2}\.\d+$/u
const DEVELOPMENT_ID_PATTERN = /^DEV-\d{8}-\d{3}$/u
const CANONICAL_REPOSITORY = 'repo://guanghulab.com/code/bingshuo/hololake-system-architecture'
const CANONICAL_REPOSITORY_ID = 'REPO-014'
const CANONICAL_SOURCE_PATH = 'product-source/hololake-platform'
const PROVENANCE_FILE_NAME = 'build-provenance.json'
function requireExactString(value, label, pattern) {
if (typeof value !== 'string' || !pattern.test(value)) {
throw new Error(`invalid ${label}`)
}
return value
}
export function buildInternalReleaseProvenance(input) {
if (input.worktreeClean !== true) {
throw new Error('internal release provenance requires a clean source worktree')
}
if (input.sourceRepository !== CANONICAL_REPOSITORY) {
throw new Error('invalid source repository')
}
if (input.sourceRepositoryId !== CANONICAL_REPOSITORY_ID) {
throw new Error('invalid source repository id')
}
if (input.sourcePath !== CANONICAL_SOURCE_PATH) {
throw new Error('invalid source path')
}
return {
schema: 'hololake.internal-release-provenance/v1',
architecture: {
id: requireExactString(input.architectureId, 'architecture id', /^HLP-[A-Z0-9-]+$/u),
version: requireExactString(input.architectureVersion, 'architecture version', ARCHITECTURE_VERSION_PATTERN),
},
build: {
application_version: requireExactString(input.applicationVersion, 'application version', VERSION_PATTERN),
development_id: requireExactString(input.developmentId, 'development id', DEVELOPMENT_ID_PATTERN),
distribution: requireExactString(input.distribution, 'distribution', /^[a-z][a-z0-9-]+$/u),
recorded_at: requireExactString(input.recordedAt, 'recorded at', /^\d{4}-\d{2}-\d{2}T/u),
target_triple: requireExactString(input.targetTriple, 'target triple', /^[a-z0-9-]+$/u),
},
source: {
branch: requireExactString(input.sourceBranch, 'source branch', /^[A-Za-z0-9._/-]+$/u),
commit: requireExactString(input.sourceCommit, 'source commit', COMMIT_PATTERN),
path: input.sourcePath,
repository: input.sourceRepository,
repository_id: input.sourceRepositoryId,
tree: requireExactString(input.sourceTree, 'source tree', COMMIT_PATTERN),
worktree_clean: true,
},
}
}
function expectEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label} mismatch`)
}
export function validateInternalReleaseProvenance(provenance, expected) {
if (!provenance || provenance.schema !== 'hololake.internal-release-provenance/v1') {
throw new Error('provenance schema mismatch')
}
expectEqual(provenance.source?.commit, expected.sourceCommit, 'source commit')
expectEqual(provenance.source?.tree, expected.sourceTree, 'source tree')
expectEqual(provenance.source?.branch, expected.sourceBranch, 'source branch')
expectEqual(provenance.source?.repository, expected.sourceRepository, 'source repository')
expectEqual(provenance.source?.repository_id, expected.sourceRepositoryId, 'source repository id')
expectEqual(provenance.source?.path, expected.sourcePath, 'source path')
expectEqual(provenance.source?.worktree_clean, expected.worktreeClean, 'source worktree state')
expectEqual(provenance.architecture?.id, expected.architectureId, 'architecture id')
expectEqual(provenance.architecture?.version, expected.architectureVersion, 'architecture version')
expectEqual(provenance.build?.application_version, expected.applicationVersion, 'application version')
expectEqual(provenance.build?.development_id, expected.developmentId, 'development id')
expectEqual(provenance.build?.distribution, expected.distribution, 'distribution')
expectEqual(provenance.build?.target_triple, expected.targetTriple, 'target triple')
return true
}
function git(repositoryRoot, ...args) {
return execFileSync('git', ['-C', repositoryRoot, ...args], { encoding: 'utf8' }).trim()
}
async function repositoryExpectation({ repositoryRoot, developmentId, distribution, targetTriple }) {
const architecture = JSON.parse(await readFile(join(repositoryRoot, 'routing/hololake-current-architecture.json'), 'utf8'))
const application = JSON.parse(await readFile(join(repositoryRoot, CANONICAL_SOURCE_PATH, 'src-tauri/tauri.conf.json'), 'utf8'))
return {
sourceCommit: git(repositoryRoot, 'rev-parse', 'HEAD'),
sourceTree: git(repositoryRoot, 'rev-parse', 'HEAD^{tree}'),
sourceBranch: git(repositoryRoot, 'branch', '--show-current'),
sourceRepository: CANONICAL_REPOSITORY,
sourceRepositoryId: CANONICAL_REPOSITORY_ID,
sourcePath: CANONICAL_SOURCE_PATH,
architectureId: architecture.architecture_id,
architectureVersion: architecture.version,
applicationVersion: application.version,
developmentId,
distribution,
targetTriple,
worktreeClean: git(repositoryRoot, 'status', '--porcelain=v1', '--untracked-files=all') === '',
}
}
async function writeJsonAtomic(outputPath, value) {
await mkdir(dirname(outputPath), { recursive: true })
const temporary = `${outputPath}.${process.pid}.tmp`
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
try {
await rename(temporary, outputPath)
} catch (error) {
await rm(temporary, { force: true })
throw error
}
}
async function findProvenanceFiles(directory) {
const found = []
for (const entry of await readdir(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) found.push(...await findProvenanceFiles(entryPath))
else if (entry.isFile() && entry.name === PROVENANCE_FILE_NAME) found.push(entryPath)
}
return found
}
function option(args, name) {
const index = args.indexOf(name)
if (index === -1 || !args[index + 1]) throw new Error(`${name} is required`)
return args[index + 1]
}
async function main() {
const [command, ...args] = process.argv.slice(2)
if (!['generate', 'verify'].includes(command)) {
throw new Error('Usage: internal-release-provenance.mjs <generate|verify> [options]')
}
const repositoryRoot = git(process.cwd(), 'rev-parse', '--show-toplevel')
const developmentId = option(args, '--development-id')
const distribution = option(args, '--distribution')
const targetTriple = option(args, '--target-triple')
const expected = await repositoryExpectation({ repositoryRoot, developmentId, distribution, targetTriple })
if (command === 'generate') {
const outputPath = resolve(option(args, '--output'))
const provenance = buildInternalReleaseProvenance({
...expected,
recordedAt: new Date().toISOString(),
})
await writeJsonAtomic(outputPath, provenance)
process.stdout.write(`${outputPath}\n`)
return
}
const bundlePath = resolve(option(args, '--bundle'))
const provenanceFiles = await findProvenanceFiles(bundlePath)
if (provenanceFiles.length !== 1) {
throw new Error(`expected exactly one packaged ${PROVENANCE_FILE_NAME}, found ${provenanceFiles.length}`)
}
const provenance = JSON.parse(await readFile(provenanceFiles[0], 'utf8'))
validateInternalReleaseProvenance(provenance, expected)
process.stdout.write(`${provenanceFiles[0]}\n`)
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
await main()
}