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:
Nikhil
2026-03-31 20:57:44 -07:00
committed by GitHub
parent f8535fd96d
commit 000429277d
7 changed files with 103 additions and 74 deletions

View File

@@ -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

View File

@@ -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}`)

View File

@@ -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",

View File

@@ -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,
}
}

View File

@@ -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 }
}

View File

@@ -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}`)

View File

@@ -22,7 +22,7 @@ export interface BuildArgs {
manifestPath: string
upload: boolean
compileOnly: boolean
archiveCompiled: boolean
ci: boolean
}
export interface R2Config {