mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
fix: isolate server release packaging to ci mode (#629)
* fix: relax compile-only release env requirements * refactor: add ci mode for server release builds
This commit is contained in:
5
.github/workflows/release-server.yml
vendored
5
.github/workflows/release-server.yml
vendored
@@ -53,11 +53,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build release artifacts
|
||||
env:
|
||||
BROWSEROS_CONFIG_URL: ${{ secrets.BROWSEROS_CONFIG_URL }}
|
||||
CODEGEN_SERVICE_URL: ${{ secrets.CODEGEN_SERVICE_URL }}
|
||||
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
run: bun run build:server:ci
|
||||
|
||||
- name: Verify release artifacts
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
// Derive the build target from the current platform so the test is portable
|
||||
function getNativeTarget(): { id: string; ext: string } {
|
||||
const os =
|
||||
process.platform === 'darwin'
|
||||
@@ -30,7 +29,22 @@ function getNativeTarget(): { id: string; ext: string } {
|
||||
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
|
||||
}
|
||||
|
||||
// Stub values so the build config validation passes without real secrets
|
||||
const REQUIRED_INLINE_ENV_KEYS = [
|
||||
'BROWSEROS_CONFIG_URL',
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'SENTRY_DSN',
|
||||
] as const
|
||||
|
||||
const R2_ENV_KEYS = [
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET',
|
||||
] as const
|
||||
|
||||
const PROD_SECRET_KEYS = [...REQUIRED_INLINE_ENV_KEYS, ...R2_ENV_KEYS]
|
||||
|
||||
const INLINE_ENV_STUBS: Record<string, string> = {
|
||||
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
|
||||
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
|
||||
@@ -53,6 +67,10 @@ describe('server build', () => {
|
||||
rootDir,
|
||||
'apps/server/.env.production.example',
|
||||
)
|
||||
const originalProdEnv = existsSync(prodEnvPath)
|
||||
? readFileSync(prodEnvPath, 'utf-8')
|
||||
: null
|
||||
const prodEnvTemplate = readFileSync(prodEnvTemplatePath, 'utf-8')
|
||||
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
|
||||
const target = getNativeTarget()
|
||||
const binaryPath = resolve(
|
||||
@@ -63,23 +81,16 @@ describe('server build', () => {
|
||||
rootDir,
|
||||
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
|
||||
)
|
||||
const createdProdEnv = !existsSync(prodEnvPath)
|
||||
|
||||
// Empty manifest so the build skips R2 resource downloads
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
|
||||
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
|
||||
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
|
||||
if (createdProdEnv) {
|
||||
writeFileSync(prodEnvPath, readFileSync(prodEnvTemplatePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function buildEnv(
|
||||
extraEnv: Record<string, string>,
|
||||
omitKeys: string[] = [],
|
||||
omitKeys: readonly string[] = [],
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...INLINE_ENV_STUBS,
|
||||
...extraEnv,
|
||||
}
|
||||
for (const key of omitKeys) {
|
||||
@@ -88,14 +99,21 @@ describe('server build', () => {
|
||||
return env
|
||||
}
|
||||
|
||||
function resetProdEnvToTemplate(): void {
|
||||
writeFileSync(prodEnvPath, prodEnvTemplate)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
if (createdProdEnv) {
|
||||
if (originalProdEnv === null) {
|
||||
rmSync(prodEnvPath, { force: true })
|
||||
return
|
||||
}
|
||||
writeFileSync(prodEnvPath, originalProdEnv)
|
||||
})
|
||||
|
||||
it('compiles and --version outputs correct version', async () => {
|
||||
resetProdEnvToTemplate()
|
||||
const pkg = await Bun.file(serverPkgPath).json()
|
||||
const expectedVersion: string = pkg.version
|
||||
|
||||
@@ -111,7 +129,7 @@ describe('server build', () => {
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: buildEnv(R2_ENV_STUBS),
|
||||
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
@@ -138,33 +156,45 @@ describe('server build', () => {
|
||||
assert.strictEqual(versionOutput.trim(), expectedVersion)
|
||||
}, 300_000)
|
||||
|
||||
it('archives compile-only builds without R2 config', async () => {
|
||||
rmSync(zipPath, { force: true })
|
||||
it('keeps compile-only on the strict production validation path', async () => {
|
||||
resetProdEnvToTemplate()
|
||||
|
||||
const build = Bun.spawn(
|
||||
[
|
||||
'bun',
|
||||
buildScript,
|
||||
`--target=${target.id}`,
|
||||
'--compile-only',
|
||||
'--archive-compiled',
|
||||
],
|
||||
['bun', buildScript, `--target=${target.id}`, '--compile-only'],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: buildEnv({}, [
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET',
|
||||
]),
|
||||
env: buildEnv({}, PROD_SECRET_KEYS),
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
const stderr = await new Response(build.stderr).text()
|
||||
|
||||
assert.notStrictEqual(buildExit, 0, 'Compile-only build should fail')
|
||||
assert.match(stderr, /Production build requires variables:/)
|
||||
assert.match(stderr, /CODEGEN_SERVICE_URL/)
|
||||
assert.match(stderr, /POSTHOG_API_KEY/)
|
||||
assert.match(stderr, /SENTRY_DSN/)
|
||||
}, 300_000)
|
||||
|
||||
it('archives CI builds without R2 config or production env secrets', async () => {
|
||||
resetProdEnvToTemplate()
|
||||
rmSync(zipPath, { force: true })
|
||||
|
||||
const build = Bun.spawn(
|
||||
['bun', buildScript, `--target=${target.id}`, '--ci'],
|
||||
{
|
||||
cwd: rootDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: buildEnv({}, PROD_SECRET_KEYS),
|
||||
},
|
||||
)
|
||||
const buildExit = await build.exited
|
||||
if (buildExit !== 0) {
|
||||
const stderr = await new Response(build.stderr).text()
|
||||
assert.fail(`Compile-only archive failed (exit ${buildExit}):\n${stderr}`)
|
||||
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"start:agent": "bun run --filter @browseros/agent dev",
|
||||
"build": "bun run build:server && bun run build:agent",
|
||||
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
|
||||
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only --archive-compiled",
|
||||
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --ci",
|
||||
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
|
||||
"upload:cli-installers": "bun scripts/build/cli.ts",
|
||||
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",
|
||||
|
||||
@@ -23,11 +23,11 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
|
||||
.option('--no-upload', 'Skip zip upload to R2')
|
||||
.option(
|
||||
'--compile-only',
|
||||
'Compile binaries only (skip R2 staging and upload)',
|
||||
'Compile binaries only (skip artifact packaging, R2 staging, and upload)',
|
||||
)
|
||||
.option(
|
||||
'--archive-compiled',
|
||||
'Archive compile-only binaries into local zip files without R2 resources',
|
||||
'--ci',
|
||||
'Build local release zip artifacts for CI without R2 and without requiring production env secrets',
|
||||
)
|
||||
program.parse(argv, { from: 'user' })
|
||||
const options = program.opts<{
|
||||
@@ -35,20 +35,23 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
|
||||
manifest: string
|
||||
upload: boolean
|
||||
compileOnly: boolean
|
||||
archiveCompiled: boolean
|
||||
ci: boolean
|
||||
}>()
|
||||
|
||||
const compileOnly = options.compileOnly ?? false
|
||||
const archiveCompiled = options.archiveCompiled ?? false
|
||||
if (archiveCompiled && !compileOnly) {
|
||||
throw new Error('--archive-compiled requires --compile-only')
|
||||
const ci = options.ci ?? false
|
||||
if (ci && compileOnly) {
|
||||
throw new Error('--ci cannot be combined with --compile-only')
|
||||
}
|
||||
if (ci && options.upload) {
|
||||
throw new Error('--ci cannot be combined with --upload')
|
||||
}
|
||||
|
||||
return {
|
||||
targets: resolveTargets(options.target),
|
||||
manifestPath: options.manifest,
|
||||
upload: compileOnly ? false : (options.upload ?? true),
|
||||
upload: ci || compileOnly ? false : (options.upload ?? true),
|
||||
compileOnly,
|
||||
archiveCompiled,
|
||||
ci,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ function validateProductionEnv(envVars: Record<string, string>): void {
|
||||
|
||||
export interface LoadBuildConfigOptions {
|
||||
compileOnly?: boolean
|
||||
ci?: boolean
|
||||
}
|
||||
|
||||
export function loadBuildConfig(
|
||||
@@ -84,7 +85,9 @@ export function loadBuildConfig(
|
||||
): BuildConfig {
|
||||
const fileEnv = loadProdEnv(rootDir)
|
||||
const envVars = buildInlineEnv(fileEnv)
|
||||
validateProductionEnv(envVars)
|
||||
if (!options.ci) {
|
||||
validateProductionEnv(envVars)
|
||||
}
|
||||
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH ?? '',
|
||||
@@ -92,7 +95,7 @@ export function loadBuildConfig(
|
||||
...process.env,
|
||||
}
|
||||
|
||||
if (options.compileOnly) {
|
||||
if (options.compileOnly || options.ci) {
|
||||
return { version: readServerVersion(rootDir), envVars, processEnv }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,9 @@ import { getTargetRules, loadManifest } from './manifest'
|
||||
import { createR2Client } from './r2'
|
||||
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
|
||||
|
||||
function buildModeLabel(argv: {
|
||||
compileOnly: boolean
|
||||
archiveCompiled: boolean
|
||||
}): string {
|
||||
if (argv.compileOnly && argv.archiveCompiled) {
|
||||
return 'compile-only+archive'
|
||||
function buildModeLabel(argv: { compileOnly: boolean; ci: boolean }): string {
|
||||
if (argv.ci) {
|
||||
return 'ci'
|
||||
}
|
||||
return argv.compileOnly ? 'compile-only' : 'full'
|
||||
}
|
||||
@@ -28,6 +25,7 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
|
||||
const buildConfig = loadBuildConfig(rootDir, {
|
||||
compileOnly: args.compileOnly,
|
||||
ci: args.ci,
|
||||
})
|
||||
|
||||
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
|
||||
@@ -41,31 +39,31 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
buildConfig.version,
|
||||
)
|
||||
|
||||
if (args.compileOnly) {
|
||||
if (args.archiveCompiled) {
|
||||
const distRoot = getDistProdRoot()
|
||||
const localArtifacts = []
|
||||
if (args.ci) {
|
||||
const distRoot = getDistProdRoot()
|
||||
const localArtifacts = []
|
||||
|
||||
for (const binary of compiled) {
|
||||
log.step(`Packaging ${binary.target.name}`)
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
buildConfig.version,
|
||||
)
|
||||
localArtifacts.push(staged)
|
||||
log.success(`Packaged ${binary.target.id}`)
|
||||
}
|
||||
|
||||
const archiveResults = await archiveArtifacts(localArtifacts)
|
||||
log.done('Compile-only archive build completed')
|
||||
for (const result of archiveResults) {
|
||||
log.info(`${result.targetId}: ${result.zipPath}`)
|
||||
}
|
||||
return
|
||||
for (const binary of compiled) {
|
||||
log.step(`Packaging ${binary.target.name}`)
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
buildConfig.version,
|
||||
)
|
||||
localArtifacts.push(staged)
|
||||
log.success(`Packaged ${binary.target.id}`)
|
||||
}
|
||||
|
||||
const archiveResults = await archiveArtifacts(localArtifacts)
|
||||
log.done('CI build completed')
|
||||
for (const result of archiveResults) {
|
||||
log.info(`${result.targetId}: ${result.zipPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (args.compileOnly) {
|
||||
log.done('Compile-only build completed')
|
||||
for (const binary of compiled) {
|
||||
log.info(`${binary.target.id}: ${binary.binaryPath}`)
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface BuildArgs {
|
||||
manifestPath: string
|
||||
upload: boolean
|
||||
compileOnly: boolean
|
||||
archiveCompiled: boolean
|
||||
ci: boolean
|
||||
}
|
||||
|
||||
export interface R2Config {
|
||||
|
||||
Reference in New Issue
Block a user