From 000429277d008a2e9c4e732b10edb26bb745b424 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 31 Mar 2026 20:57:44 -0700 Subject: [PATCH] fix: isolate server release packaging to ci mode (#629) * fix: relax compile-only release env requirements * refactor: add ci mode for server release builds --- .github/workflows/release-server.yml | 5 -- .../apps/server/tests/build.test.ts | 86 +++++++++++++------ packages/browseros-agent/package.json | 2 +- .../scripts/build/server/cli.ts | 21 +++-- .../scripts/build/server/config.ts | 7 +- .../scripts/build/server/orchestrator.ts | 54 ++++++------ .../scripts/build/server/types.ts | 2 +- 7 files changed, 103 insertions(+), 74 deletions(-) diff --git a/.github/workflows/release-server.yml b/.github/workflows/release-server.yml index 3585b24ef..452978c2c 100644 --- a/.github/workflows/release-server.yml +++ b/.github/workflows/release-server.yml @@ -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 diff --git a/packages/browseros-agent/apps/server/tests/build.test.ts b/packages/browseros-agent/apps/server/tests/build.test.ts index 89aee2282..4cbb16ea3 100644 --- a/packages/browseros-agent/apps/server/tests/build.test.ts +++ b/packages/browseros-agent/apps/server/tests/build.test.ts @@ -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 = { 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, - 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}`) diff --git a/packages/browseros-agent/package.json b/packages/browseros-agent/package.json index ba2a9681f..0779e04bb 100644 --- a/packages/browseros-agent/package.json +++ b/packages/browseros-agent/package.json @@ -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", diff --git a/packages/browseros-agent/scripts/build/server/cli.ts b/packages/browseros-agent/scripts/build/server/cli.ts index f1d80399e..24acbd39b 100644 --- a/packages/browseros-agent/scripts/build/server/cli.ts +++ b/packages/browseros-agent/scripts/build/server/cli.ts @@ -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, } } diff --git a/packages/browseros-agent/scripts/build/server/config.ts b/packages/browseros-agent/scripts/build/server/config.ts index f9cad746d..27a2accc8 100644 --- a/packages/browseros-agent/scripts/build/server/config.ts +++ b/packages/browseros-agent/scripts/build/server/config.ts @@ -76,6 +76,7 @@ function validateProductionEnv(envVars: Record): 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 } } diff --git a/packages/browseros-agent/scripts/build/server/orchestrator.ts b/packages/browseros-agent/scripts/build/server/orchestrator.ts index 1f49714b9..fa86c0e9e 100644 --- a/packages/browseros-agent/scripts/build/server/orchestrator.ts +++ b/packages/browseros-agent/scripts/build/server/orchestrator.ts @@ -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 { 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 { 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}`) diff --git a/packages/browseros-agent/scripts/build/server/types.ts b/packages/browseros-agent/scripts/build/server/types.ts index 3efda1139..466835575 100644 --- a/packages/browseros-agent/scripts/build/server/types.ts +++ b/packages/browseros-agent/scripts/build/server/types.ts @@ -22,7 +22,7 @@ export interface BuildArgs { manifestPath: string upload: boolean compileOnly: boolean - archiveCompiled: boolean + ci: boolean } export interface R2Config {