feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const mode = process.argv[2] ?? 'quick'
|
||||
if (!['quick', 'full'].includes(mode)) {
|
||||
console.error(`Unknown acceptance mode: ${mode}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
const cargoHomeBinary = join(homedir(), '.cargo', 'bin', process.platform === 'win32' ? 'cargo.exe' : 'cargo')
|
||||
const cargo = process.env.CARGO || (existsSync(cargoHomeBinary) ? cargoHomeBinary : 'cargo')
|
||||
const manifest = 'src-tauri/Cargo.toml'
|
||||
|
||||
const quickSteps = [
|
||||
['Renderer type contract', npm, ['run', 'typecheck:ui']],
|
||||
['Test and mock contracts', npm, ['run', 'typecheck:test']],
|
||||
['Unit and foundation smoke tests', npm, ['test']],
|
||||
['Rust formatting', cargo, ['fmt', '--manifest-path', manifest, '--', '--check']],
|
||||
['Rust compile check', cargo, ['check', '--manifest-path', manifest]],
|
||||
]
|
||||
|
||||
const fullSteps = [
|
||||
['TypeScript and React lint', npm, ['run', 'lint']],
|
||||
...quickSteps,
|
||||
['Rust lint with warnings denied', cargo, ['clippy', '--manifest-path', manifest, '--all-targets', '--', '-D', 'warnings']],
|
||||
['Rust tests', cargo, ['test', '--manifest-path', manifest]],
|
||||
['Renderer production build', npm, ['run', 'build:ui']],
|
||||
['Release tooling tests', npm, ['run', 'test:release']],
|
||||
]
|
||||
|
||||
const steps = mode === 'full' ? fullSteps : quickSteps
|
||||
const startedAt = Date.now()
|
||||
|
||||
for (const [label, command, args] of steps) {
|
||||
console.log(`\n[acceptance] ${label}`)
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[acceptance] Unable to run ${command}: ${result.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`[acceptance] FAILED: ${label}`)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1)
|
||||
console.log(`\n[acceptance] PASS (${mode}, ${elapsedSeconds}s)`)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { missingSigningVariables } from './release-tools.mjs'
|
||||
|
||||
const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '')
|
||||
if (!platform) {
|
||||
console.error('[release] Only macOS and Windows release targets are supported')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const requireSigning = process.env.REQUIRE_SIGNING === 'true'
|
||||
const missing = missingSigningVariables(platform)
|
||||
if (requireSigning && missing.length > 0) {
|
||||
console.error(`[release] ${platform} signing is required but these variables are missing: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`[release] ${platform} preflight passed (${requireSigning ? 'signed release required' : missing.length ? 'unsigned development build' : 'signing configured'})`)
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
export const REQUIRED_SIGNING_ENV = {
|
||||
macos: [
|
||||
'APPLE_CERTIFICATE',
|
||||
'APPLE_CERTIFICATE_PASSWORD',
|
||||
'APPLE_SIGNING_IDENTITY',
|
||||
'APPLE_ID',
|
||||
'APPLE_PASSWORD',
|
||||
'APPLE_TEAM_ID',
|
||||
],
|
||||
windows: ['WINDOWS_CERTIFICATE_BASE64', 'WINDOWS_CERTIFICATE_PASSWORD'],
|
||||
}
|
||||
|
||||
export function missingSigningVariables(platform, env = process.env) {
|
||||
const required = REQUIRED_SIGNING_ENV[platform]
|
||||
if (!required) throw new Error(`Unsupported release platform: ${platform}`)
|
||||
return required.filter(name => !env[name]?.trim())
|
||||
}
|
||||
|
||||
export function readPeMachine(path) {
|
||||
const buffer = readFileSync(path)
|
||||
if (buffer.length < 64 || buffer.readUInt16LE(0) !== 0x5a4d) {
|
||||
throw new Error(`${path} is not a PE executable`)
|
||||
}
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (peOffset + 6 > buffer.length || buffer.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
|
||||
throw new Error(`${path} has an invalid PE header`)
|
||||
}
|
||||
return buffer.readUInt16LE(peOffset + 4)
|
||||
}
|
||||
|
||||
export function assertWithinSize(path, maxBytes) {
|
||||
const size = readFileSync(path).byteLength
|
||||
if (size > maxBytes) {
|
||||
throw new Error(`${path} is ${(size / 1024 / 1024).toFixed(1)} MB; limit is ${(maxBytes / 1024 / 1024).toFixed(1)} MB`)
|
||||
}
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { assertWithinSize, missingSigningVariables, readPeMachine, REQUIRED_SIGNING_ENV } from './release-tools.mjs'
|
||||
|
||||
test('signing preflight reports every missing secret', () => {
|
||||
assert.deepEqual(missingSigningVariables('windows', {}), REQUIRED_SIGNING_ENV.windows)
|
||||
const env = Object.fromEntries(REQUIRED_SIGNING_ENV.macos.map(key => [key, 'configured']))
|
||||
assert.deepEqual(missingSigningVariables('macos', env), [])
|
||||
})
|
||||
|
||||
test('PE reader accepts x86_64 and rejects malformed files', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opsxshell-release-'))
|
||||
const pe = Buffer.alloc(128)
|
||||
pe.writeUInt16LE(0x5a4d, 0)
|
||||
pe.writeUInt32LE(64, 0x3c)
|
||||
pe.write('PE\0\0', 64, 'ascii')
|
||||
pe.writeUInt16LE(0x8664, 68)
|
||||
const path = join(dir, 'app.exe')
|
||||
writeFileSync(path, pe)
|
||||
assert.equal(readPeMachine(path), 0x8664)
|
||||
assert.throws(() => assertWithinSize(path, 64), /limit is/)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { assertWithinSize, readPeMachine } from './release-tools.mjs'
|
||||
|
||||
const platform = process.argv[2] ?? (process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : '')
|
||||
const root = process.argv[3] ?? (platform === 'macos'
|
||||
? 'src-tauri/target/aarch64-apple-darwin/release/bundle'
|
||||
: 'src-tauri/target/release/bundle')
|
||||
const requireSigning = process.env.REQUIRE_SIGNING === 'true'
|
||||
const maxBinaryBytes = Number(process.env.MAX_RELEASE_BINARY_MB ?? 40) * 1024 * 1024
|
||||
const maxInstallerBytes = Number(process.env.MAX_RELEASE_INSTALLER_MB ?? 40) * 1024 * 1024
|
||||
|
||||
function walk(dir) {
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(dir, entry.name)
|
||||
return entry.isDirectory() ? walk(path) : [path]
|
||||
})
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[release] ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const files = walk(root)
|
||||
if (files.length === 0) fail(`No release artifacts found under ${root}`)
|
||||
|
||||
if (platform === 'macos') {
|
||||
const appExecutable = files.find(path => /\.app\/Contents\/MacOS\/[^/]+$/.test(path))
|
||||
const dmg = files.find(path => path.includes('/dmg/') && path.endsWith('.dmg'))
|
||||
if (!appExecutable || !dmg) fail('macOS release must contain an .app executable and a .dmg')
|
||||
assertWithinSize(appExecutable, maxBinaryBytes)
|
||||
assertWithinSize(dmg, maxInstallerBytes)
|
||||
const architectures = execFileSync('lipo', ['-archs', appExecutable], { encoding: 'utf8' }).trim().split(/\s+/)
|
||||
if (!architectures.includes('arm64')) fail(`Expected arm64 app, found: ${architectures.join(', ')}`)
|
||||
if (requireSigning) {
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { stdio: 'inherit' })
|
||||
const inspection = spawnSync('codesign', ['-dv', '--verbose=4', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { encoding: 'utf8' })
|
||||
if (inspection.status !== 0) fail(`Unable to inspect macOS signature: ${inspection.stderr}`)
|
||||
const detail = `${inspection.stdout}\n${inspection.stderr}`
|
||||
if (/Signature=adhoc/.test(detail)) fail('macOS application is only ad-hoc signed')
|
||||
execFileSync('spctl', ['--assess', '--type', 'execute', '--verbose=2', appExecutable.slice(0, appExecutable.indexOf('.app/') + 4)], { stdio: 'inherit' })
|
||||
}
|
||||
console.log(`[release] macOS arm64 artifacts verified: ${root}`)
|
||||
} else if (platform === 'windows') {
|
||||
const executables = files.filter(path => path.toLowerCase().endsWith('.exe'))
|
||||
const installers = files.filter(path => /\.(?:msi|exe)$/i.test(path))
|
||||
if (executables.length === 0 || installers.length === 0) fail('Windows release must contain an x64 executable or installer')
|
||||
for (const executable of executables) {
|
||||
assertWithinSize(executable, maxBinaryBytes)
|
||||
if (readPeMachine(executable) !== 0x8664) fail(`${executable} is not an x86_64 PE executable`)
|
||||
if (requireSigning) {
|
||||
execFileSync('powershell', ['-NoProfile', '-Command', `$s = Get-AuthenticodeSignature -LiteralPath '${executable.replaceAll("'", "''")}'; if ($s.Status -ne 'Valid') { throw \"Invalid signature: $($s.Status)\" }`], { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
for (const installer of installers) assertWithinSize(installer, maxInstallerBytes)
|
||||
console.log(`[release] Windows x64 artifacts verified: ${root}`)
|
||||
} else {
|
||||
fail(`Unsupported platform: ${platform}`)
|
||||
}
|
||||
Reference in new issue
Block a user