#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const args = parseArgs(process.argv.slice(2))
const tagVersion = args.tag ? versionFromTag(args.tag) : ''
const version = tagVersion || args.version || pkg.version
if (args.version && tagVersion && args.version !== tagVersion) {
fail(`--version ${args.version} does not match --tag ${args.tag}`)
}
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
fail(`release version must look like X.Y.Z, got ${version}`)
}
if (version !== pkg.version) {
fail(`release version ${version} does not match package.json version ${pkg.version}`)
}
const tag = args.tag ? normalizeTag(args.tag) : `v${version}`
const platform = normalizePlatform(args.platform || process.platform)
const releasePlatform = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'
const arch = normalizeArch(args.arch || process.env.RUNNER_ARCH || process.arch)
const buildDir = resolve(args.buildDir || 'appling/build')
const releaseRoot = resolve('dist', 'appling-release')
const outDir = resolve(args.outDir || join(releaseRoot, tag, releasePlatform))
const appName = sanitizeName(args.appName || 'PearBrowser')
const filePatterns = {
darwin: [/\.dmg$/i, /\.pkg$/i, /\.zip$/i],
win32: [/\.exe$/i, /\.msi$/i, /\.msix$/i, /\.zip$/i],
linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i, /\.snap$/i, /\.tar\.gz$/i, /\.tgz$/i, /\.tar\.xz$/i, /\.zip$/i]
}
if (!existsSync(buildDir)) {
fail(`build directory does not exist: ${buildDir}`)
}
assertSafeOutputDir(outDir)
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
const artifacts = findArtifacts(buildDir, platform)
if (artifacts.length === 0) {
console.error(`error: no ${releasePlatform} appling artifacts found under ${buildDir}`)
console.error(' run `npm run --prefix appling generate` and `npm run --prefix appling build` first')
process.exit(1)
}
if (platform === 'linux') {
const appImages = artifacts.filter((artifact) => /\.AppImage$/i.test(artifact.path))
if (appImages.length > 1) {
fail(`expected exactly one PearBrowser AppImage under ${buildDir}, found ${appImages.length}: ${appImages.map((artifact) => relative(resolve('.'), artifact.path)).join(', ')}`)
}
}
const outputs = []
const extCounts = new Map()
for (const artifact of artifacts) {
const ext = artifact.kind === 'app' ? '.app.zip' : artifactExtension(artifact.path)
const extCount = extCounts.get(ext) || 0
extCounts.set(ext, extCount + 1)
const originalName = stripKnownExtension(basename(artifact.path))
const duplicateSuffix = extCount === 0 ? '' : `-${sanitizeName(originalName)}`
const assetName = `${appName}-${version}-${releasePlatform}-${arch}${duplicateSuffix}${ext}`
const destination = join(outDir, assetName)
if (artifact.kind === 'app') archiveAppBundle(artifact.path, destination)
else copyFileSync(artifact.path, destination)
const sha256 = hashFile(destination)
writeFileSync(`${destination}.sha256`, `${sha256} ${assetName}\n`)
outputs.push({
name: assetName,
sha256,
source: relative(resolve('.'), artifact.path),
bytes: statSync(destination).size
})
}
const checksumName = `SHA256SUMS-${releasePlatform}-${arch}.txt`
writeFileSync(
join(outDir, checksumName),
outputs.map((asset) => `${asset.sha256} ${asset.name}`).join('\n') + '\n'
)
writeFileSync(
join(outDir, `manifest-${releasePlatform}-${arch}.json`),
JSON.stringify({ tag, version, platform: releasePlatform, arch, artifacts: outputs }, null, 2) + '\n'
)
console.log(`Collected ${outputs.length} ${releasePlatform} appling artifact(s):`)
for (const output of outputs) {
console.log(`- ${output.name} (${output.bytes} bytes)`)
}
console.log(`Output: ${outDir}`)
function parseArgs (argv) {
const options = new Map([
['--platform', 'platform'],
['--arch', 'arch'],
['--version', 'version'],
['--tag', 'tag'],
['--build-dir', 'buildDir'],
['--out-dir', 'outDir'],
['--app-name', 'appName']
])
const parsed = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const key = options.get(arg)
if (!key) fail(`unknown argument: ${arg}`)
const value = argv[++i] || ''
if (!value || value.startsWith('--')) fail(`${arg} requires a value`)
parsed[key] = value
}
return parsed
}
function versionFromTag (tag) {
return normalizeTag(tag).slice(1)
}
function normalizeTag (tag) {
const normalized = String(tag || '').replace(/^refs\/tags\//, '')
if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized)) {
fail(`release tag must look like vX.Y.Z, got ${tag}`)
}
return normalized
}
function normalizePlatform (value) {
const platform = String(value).toLowerCase()
if (platform === 'macos' || platform === 'mac' || platform === 'darwin') return 'darwin'
if (platform === 'windows' || platform === 'win' || platform === 'win32') return 'win32'
if (platform === 'linux') return 'linux'
fail(`unsupported platform: ${value}`)
}
function normalizeArch (value) {
const arch = String(value).toLowerCase()
if (arch === 'x64' || arch === 'x86_64' || arch === 'amd64') return 'x64'
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return sanitizeName(arch)
}
function assertSafeOutputDir (dir) {
const cwd = resolve('.')
const forbidden = new Set([
cwd,
resolve('/'),
releaseRoot,
resolve('appling'),
buildDir,
dirname(buildDir)
])
if (process.env.HOME) forbidden.add(resolve(process.env.HOME))
if (!isWithin(dir, releaseRoot) || forbidden.has(dir)) {
fail(`refusing to clear unsafe output directory: ${dir} (must be below ${releaseRoot})`)
}
for (const unsafeRoot of [resolve('appling'), dirname(buildDir), buildDir]) {
if (isSameOrWithin(dir, unsafeRoot) || isSameOrWithin(unsafeRoot, dir)) {
fail(`refusing to clear unsafe output directory: ${dir} (overlaps ${unsafeRoot})`)
}
}
}
function isWithin (target, root) {
const path = relative(root, target)
return path !== '' && !path.startsWith('..') && !isAbsolute(path)
}
function isSameOrWithin (target, root) {
const path = relative(root, target)
return path === '' || (!path.startsWith('..') && !isAbsolute(path))
}
function findArtifacts (root, targetPlatform) {
const found = []
walk(root)
return found.sort((a, b) => a.path.localeCompare(b.path))
function walk (dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name.endsWith('.app') && targetPlatform === 'darwin') {
found.push({ kind: 'app', path })
continue
}
if (shouldSkipDir(entry.name)) continue
walk(path)
} else if (entry.isFile() && isReleaseFile(entry.name, targetPlatform) && !isPackagingToolArtifact(entry.name, targetPlatform) && !isRawWindowsLauncher(entry.name, targetPlatform)) {
const stats = statSync(path)
if (stats.size > 0) found.push({ kind: 'file', path })
}
}
}
}
function shouldSkipDir (name) {
return name === 'CMakeFiles' || name === '_deps' || name === '.cmake' || name === 'node_modules'
}
function isReleaseFile (name, targetPlatform) {
return filePatterns[targetPlatform].some((pattern) => pattern.test(name))
}
// cmake-pear builds AppImageTool as an implementation detail beside the actual
// PearBrowser image. It is executable and ends in .AppImage, but it is not an
// application release artifact. Collecting it previously made the tool image
// the normalized Linux download because it sorted before PearBrowser.AppImage.
function isPackagingToolArtifact (name, targetPlatform) {
return targetPlatform === 'linux' && /^(?:appimagetool|linuxdeploy)(?:[-_.].*)?\.AppImage$/i.test(name)
}
// The bare-pear Windows build emits a raw "<AppName>.exe" launcher next to the
// .msix. It is the app executable, not an installer, and shipping it alongside
// the package confuses the download surface — drop it. A real installer (e.g.
// "<AppName> Setup.exe") has a different basename and is still collected.
function isRawWindowsLauncher (name, targetPlatform) {
return targetPlatform === 'win32' && name.toLowerCase() === `${appName}.exe`.toLowerCase()
}
function artifactExtension (path) {
const name = basename(path)
for (const ext of ['.tar.gz', '.tar.xz', '.tar.bz2', '.AppImage']) {
if (name.endsWith(ext)) return ext
}
return extname(name)
}
function stripKnownExtension (name) {
const ext = artifactExtension(name)
return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name
}
function sanitizeName (name) {
return String(name)
.replace(/\.[A-Za-z0-9.]+$/, '')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'artifact'
}
function archiveAppBundle (appPath, destination) {
const ditto = spawnSync('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, destination], {
stdio: 'inherit'
})
if (ditto.status === 0) return
const zip = spawnSync('zip', ['-qry', destination, basename(appPath)], {
cwd: dirname(appPath),
stdio: 'inherit'
})
if (zip.status !== 0) {
throw new Error(`failed to zip app bundle ${appPath}`)
}
}
function hashFile (path) {
const hash = createHash('sha256')
hash.update(readFileSync(path))
return hash.digest('hex')
}
function fail (message) {
console.error(`error: ${message}`)
process.exit(1)
}