mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-19 19:41:06 +00:00
* feat: add browseros-cli self-updater * fix: address review comments for 0327-cli_self_updater * fix: address PR review comments for 0327-cli_self_updater * fix: replace goreleaser with Makefile-based release build Remove .goreleaser.yml (required Pro license for monorepo field) and consolidate cross-compilation into `make release`. CI now uses the same Makefile target, fixing a bug where POSTHOG_API_KEY was missing from release ldflags. * fix: address critical self-updater bugs from code review - Fix SHA256 checksum mismatch: verify archive checksum before extraction instead of verifying extracted binary against archive hash (was always failing). Add VerifyChecksum() and integration test. - Fix JSON field name mismatch: TypeScript was emitting camelCase (publishedAt, archiveFormat) but Go expected snake_case (published_at, archive_format). Manifest parsing was silently broken. - Add decompression size limit (256 MB) to prevent zip/gzip bombs. - Don't update LastCheckedAt on transient errors so retry happens on next CLI invocation instead of waiting 24h.
288 lines
8.2 KiB
TypeScript
288 lines
8.2 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { log } from '../log'
|
|
import { createR2Client, joinObjectKey, uploadFileToObject } from '../server/r2'
|
|
import { type CliUploadConfig, loadCliUploadConfig } from './config'
|
|
|
|
const CDN_BASE_URL = 'https://cdn.browseros.com'
|
|
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'
|
|
const CLI_ARCHIVE_PATTERN =
|
|
/^browseros-cli_(?<version>[^_]+)_(?<os>darwin|linux|windows)_(?<arch>amd64|arm64)\.(?<ext>tar\.gz|zip)$/
|
|
|
|
const INSTALLERS = [
|
|
{
|
|
filePath: join('apps', 'cli', 'scripts', 'install.sh'),
|
|
objectName: 'install.sh',
|
|
contentType: 'text/x-shellscript; charset=utf-8',
|
|
},
|
|
{
|
|
filePath: join('apps', 'cli', 'scripts', 'install.ps1'),
|
|
objectName: 'install.ps1',
|
|
contentType: 'text/plain; charset=utf-8',
|
|
},
|
|
] as const
|
|
|
|
export interface CliReleaseOptions {
|
|
version: string
|
|
binariesDir: string
|
|
}
|
|
|
|
export interface CliReleaseAsset {
|
|
filename: string
|
|
url: string
|
|
archive_format: 'tar.gz' | 'zip'
|
|
sha256: string
|
|
}
|
|
|
|
export interface CliReleaseManifest {
|
|
version: string
|
|
published_at: string
|
|
tag: string
|
|
assets: Record<string, CliReleaseAsset>
|
|
}
|
|
|
|
interface CliArchiveMetadata {
|
|
filename: string
|
|
version: string
|
|
os: string
|
|
arch: string
|
|
archive_format: 'tar.gz' | 'zip'
|
|
}
|
|
|
|
function resolveRootDir(): string {
|
|
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
process.chdir(rootDir)
|
|
return rootDir
|
|
}
|
|
|
|
export async function runCliInstallerUpload(): Promise<void> {
|
|
await uploadCliInstallers(resolveRootDir())
|
|
}
|
|
|
|
export async function runCliRelease(options: CliReleaseOptions): Promise<void> {
|
|
await uploadCliRelease(resolveRootDir(), options)
|
|
}
|
|
|
|
export async function uploadCliInstallers(rootDir: string): Promise<void> {
|
|
const { r2 } = loadCliUploadConfig(rootDir)
|
|
const client = createR2Client(r2)
|
|
|
|
log.header('Uploading BrowserOS CLI installer scripts')
|
|
|
|
try {
|
|
for (const installer of INSTALLERS) {
|
|
const absolutePath = join(rootDir, installer.filePath)
|
|
if (!existsSync(absolutePath)) {
|
|
throw new Error(`Installer script not found: ${installer.filePath}`)
|
|
}
|
|
|
|
const objectKey = joinObjectKey(r2.uploadPrefix, installer.objectName)
|
|
log.step(`Uploading ${installer.filePath}`)
|
|
await uploadFileToObject(client, r2, objectKey, absolutePath, {
|
|
contentType: installer.contentType,
|
|
})
|
|
log.success(`Uploaded ${objectKey}`)
|
|
log.info(`${CDN_BASE_URL}/${objectKey}`)
|
|
}
|
|
|
|
log.done('CLI installer upload completed')
|
|
} finally {
|
|
client.destroy()
|
|
}
|
|
}
|
|
|
|
export function parseCliChecksums(contents: string): Map<string, string> {
|
|
const entries = new Map<string, string>()
|
|
for (const rawLine of contents.split('\n')) {
|
|
const line = rawLine.trim()
|
|
if (!line) {
|
|
continue
|
|
}
|
|
const match = line.match(/^([a-f0-9]{64})\s+\*?(.+)$/i)
|
|
if (!match) {
|
|
throw new Error(`Invalid checksum line: ${rawLine}`)
|
|
}
|
|
entries.set(match[2], match[1].toLowerCase())
|
|
}
|
|
return entries
|
|
}
|
|
|
|
export function parseCliArchiveFilename(
|
|
filename: string,
|
|
): CliArchiveMetadata | null {
|
|
const match = filename.match(CLI_ARCHIVE_PATTERN)
|
|
if (!match?.groups) {
|
|
return null
|
|
}
|
|
const archive_format = match.groups.ext as 'tar.gz' | 'zip'
|
|
return {
|
|
filename,
|
|
version: match.groups.version,
|
|
os: match.groups.os,
|
|
arch: match.groups.arch,
|
|
archive_format,
|
|
}
|
|
}
|
|
|
|
export function buildCliReleaseManifest(options: {
|
|
version: string
|
|
filenames: string[]
|
|
checksumsContent: string
|
|
published_at?: string
|
|
cdnBaseURL?: string
|
|
uploadPrefix?: string
|
|
}): CliReleaseManifest {
|
|
const checksumByFilename = parseCliChecksums(options.checksumsContent)
|
|
const assets: Record<string, CliReleaseAsset> = {}
|
|
const filenames = [...options.filenames].sort()
|
|
const cdnBaseURL = options.cdnBaseURL ?? CDN_BASE_URL
|
|
const uploadPrefix = options.uploadPrefix ?? 'cli'
|
|
|
|
for (const filename of filenames) {
|
|
const archive = parseCliArchiveFilename(filename)
|
|
if (archive === null) {
|
|
throw new Error(`Unexpected CLI archive filename: ${filename}`)
|
|
}
|
|
if (archive.version !== options.version) {
|
|
throw new Error(
|
|
`Archive ${filename} does not match release version ${options.version}`,
|
|
)
|
|
}
|
|
|
|
const checksum = checksumByFilename.get(filename)
|
|
if (!checksum) {
|
|
throw new Error(`Missing checksum for ${filename}`)
|
|
}
|
|
|
|
const assetKey = `${archive.os}/${archive.arch}`
|
|
assets[assetKey] = {
|
|
filename,
|
|
url: `${cdnBaseURL}/${joinObjectKey(uploadPrefix, `v${options.version}`, filename)}`,
|
|
archive_format: archive.archive_format,
|
|
sha256: checksum,
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: options.version,
|
|
published_at: options.published_at ?? new Date().toISOString(),
|
|
tag: `browseros-cli-v${options.version}`,
|
|
assets,
|
|
}
|
|
}
|
|
|
|
async function uploadCliManifest(
|
|
client: ReturnType<typeof createR2Client>,
|
|
version: string,
|
|
releaseArchives: string[],
|
|
uploadPrefix: string,
|
|
absoluteBinariesDir: string,
|
|
r2: CliUploadConfig['r2'],
|
|
): Promise<void> {
|
|
const checksumsPath = join(absoluteBinariesDir, 'checksums.txt')
|
|
if (!existsSync(checksumsPath)) {
|
|
throw new Error('checksums.txt is required to build CLI manifest')
|
|
}
|
|
|
|
const manifest = buildCliReleaseManifest({
|
|
version,
|
|
filenames: releaseArchives,
|
|
checksumsContent: readFileSync(checksumsPath, 'utf-8'),
|
|
uploadPrefix,
|
|
})
|
|
const manifestPath = join(tmpdir(), `browseros-cli-manifest-${version}.json`)
|
|
await writeFile(
|
|
manifestPath,
|
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
'utf-8',
|
|
)
|
|
|
|
const versionedKey = joinObjectKey(
|
|
uploadPrefix,
|
|
`v${version}`,
|
|
'manifest.json',
|
|
)
|
|
const latestKey = joinObjectKey(uploadPrefix, 'latest', 'manifest.json')
|
|
|
|
log.step('Uploading manifest.json')
|
|
await uploadFileToObject(client, r2, versionedKey, manifestPath, {
|
|
contentType: JSON_CONTENT_TYPE,
|
|
})
|
|
await uploadFileToObject(client, r2, latestKey, manifestPath, {
|
|
contentType: JSON_CONTENT_TYPE,
|
|
})
|
|
log.success(`Uploaded ${latestKey}`)
|
|
log.info(`${CDN_BASE_URL}/${latestKey}`)
|
|
}
|
|
|
|
async function uploadCliRelease(
|
|
rootDir: string,
|
|
options: CliReleaseOptions,
|
|
): Promise<void> {
|
|
const { version, binariesDir } = options
|
|
const absoluteBinariesDir = resolve(rootDir, binariesDir)
|
|
|
|
if (!existsSync(absoluteBinariesDir)) {
|
|
throw new Error(`Binaries directory not found: ${binariesDir}`)
|
|
}
|
|
|
|
const archives = readdirSync(absoluteBinariesDir).filter(
|
|
(f) => f.endsWith('.tar.gz') || f.endsWith('.zip') || f === 'checksums.txt',
|
|
)
|
|
if (archives.length === 0) {
|
|
throw new Error(`No archives found in ${binariesDir}`)
|
|
}
|
|
const releaseArchives = archives.filter((f) => f !== 'checksums.txt')
|
|
|
|
const { r2 } = loadCliUploadConfig(rootDir)
|
|
const client = createR2Client(r2)
|
|
|
|
log.header(`Uploading BrowserOS CLI v${version} release`)
|
|
|
|
try {
|
|
for (const filename of archives) {
|
|
const filePath = join(absoluteBinariesDir, filename)
|
|
const versionedKey = joinObjectKey(
|
|
r2.uploadPrefix,
|
|
`v${version}`,
|
|
filename,
|
|
)
|
|
const latestKey = joinObjectKey(r2.uploadPrefix, 'latest', filename)
|
|
|
|
log.step(`Uploading ${filename}`)
|
|
await uploadFileToObject(client, r2, versionedKey, filePath)
|
|
await uploadFileToObject(client, r2, latestKey, filePath)
|
|
log.success(`Uploaded ${filename}`)
|
|
log.info(`${CDN_BASE_URL}/${versionedKey}`)
|
|
}
|
|
|
|
await uploadCliManifest(
|
|
client,
|
|
version,
|
|
releaseArchives,
|
|
r2.uploadPrefix,
|
|
absoluteBinariesDir,
|
|
r2,
|
|
)
|
|
|
|
const versionTxtPath = join(tmpdir(), 'browseros-cli-version.txt')
|
|
await writeFile(versionTxtPath, version, 'utf-8')
|
|
const versionKey = joinObjectKey(r2.uploadPrefix, 'latest', 'version.txt')
|
|
await uploadFileToObject(client, r2, versionKey, versionTxtPath, {
|
|
contentType: 'text/plain; charset=utf-8',
|
|
})
|
|
log.success(`Uploaded ${versionKey}`)
|
|
log.info(`${CDN_BASE_URL}/${versionKey}`)
|
|
|
|
log.done('CLI binary upload completed')
|
|
} finally {
|
|
client.destroy()
|
|
}
|
|
|
|
await uploadCliInstallers(rootDir)
|
|
}
|