From 6c053a5f2921f09eb5a3ebfb4f93f3bb6d4cc332 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 27 Mar 2026 11:47:31 -0700 Subject: [PATCH] feat: upload CLI binaries to CDN and gate release to core team (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: upload CLI binaries to CDN during release and gate workflow to core team - Extend scripts/build/cli/upload.ts with uploadCliRelease() that pushes archives + checksums to R2 under versioned (cli/v{VERSION}/) and latest (cli/latest/) paths, plus a version.txt for lightweight latest resolution - Update scripts/build/cli.ts entry point with --release/--version/--binaries-dir flags (existing no-args behavior preserved for upload:cli-installers) - Rewrite install.sh and install.ps1 to fetch from cdn.browseros.com instead of GitHub releases API — eliminates rate limits and API dependency - Add environment: release-core to release-cli.yml for core-team gating via GitHub environment protection rules - Add Bun setup + CDN upload step to the workflow between build and GitHub release * fix: address review feedback for PR #602 - Make loadProdEnv return empty map when .env.production is absent so pickEnv falls through to process.env in CI (Greptile P1) - Add semver format validation for version string in install.sh and install.ps1 to guard against malformed CDN responses - Pass inputs.version via env var instead of inline ${{ }} interpolation to prevent command injection in workflow shell --- .github/workflows/release-cli.yml | 24 ++++++ .../apps/cli/scripts/install.ps1 | 20 ++--- .../apps/cli/scripts/install.sh | 20 +++-- packages/browseros-agent/scripts/build/cli.ts | 42 +++++++++- .../scripts/build/cli/config.ts | 12 +-- .../scripts/build/cli/upload.ts | 78 ++++++++++++++++++- 6 files changed, 160 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 5eb16315e..aafe99d57 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -16,6 +16,7 @@ jobs: release: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + environment: release-core permissions: contents: write pull-requests: write @@ -32,6 +33,10 @@ jobs: with: go-version-file: packages/browseros-agent/apps/cli/go.mod + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.6" + - name: Run tests run: go test ./... -v @@ -68,6 +73,25 @@ jobs: echo "=== Built artifacts ===" ls -lh "$DIST" + - name: Install dependencies + run: bun install + working-directory: packages/browseros-agent + + - name: Upload to CDN + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_UPLOAD_PREFIX: cli + CLI_VERSION: ${{ inputs.version }} + run: | + bun scripts/build/cli.ts \ + --release \ + --version "$CLI_VERSION" \ + --binaries-dir apps/cli/dist + working-directory: packages/browseros-agent + - name: Generate release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/browseros-agent/apps/cli/scripts/install.ps1 b/packages/browseros-agent/apps/cli/scripts/install.ps1 index 6adfe4da7..28ba86119 100644 --- a/packages/browseros-agent/apps/cli/scripts/install.ps1 +++ b/packages/browseros-agent/apps/cli/scripts/install.ps1 @@ -17,10 +17,10 @@ param( $ErrorActionPreference = "Stop" -# TLS 1.2 — required for GitHub, older PS 5.1 defaults to TLS 1.0 +# TLS 1.2 — older PS 5.1 defaults to TLS 1.0 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$Repo = "browseros-ai/BrowserOS" +$CdnBase = "https://cdn.browseros.com/cli" $Binary = "browseros-cli" # When piped via irm | iex, param() is ignored — fall back to env vars @@ -31,15 +31,16 @@ if (-not $Dir) { $Dir = if ($env:BROWSEROS_DIR) { $env:BROWSEROS_DIR } else { "$ if (-not $Version) { Write-Host "Fetching latest version..." - $releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases?per_page=100" - $tag = ($releases ` - | Where-Object { $_.tag_name -match "^browseros-cli-v" -and $_.tag_name -notmatch "-rc" } ` - | Select-Object -First 1).tag_name - if (-not $tag) { + $Version = (Invoke-WebRequest -Uri "$CdnBase/latest/version.txt" -UseBasicParsing).Content.Trim() + if (-not $Version) { Write-Error "Could not determine latest version. Try: -Version 0.1.0" exit 1 } - $Version = $tag -replace "^browseros-cli-v", "" +} + +if ($Version -notmatch '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$') { + Write-Error "Unexpected version format: '$Version'" + exit 1 } Write-Host "Installing browseros-cli v$Version..." @@ -65,9 +66,8 @@ if (-not [Environment]::Is64BitOperatingSystem) { # ── Download and extract ───────────────────────────────────────────────────── -$Tag = "browseros-cli-v$Version" $Filename = "${Binary}_${Version}_windows_${Arch}.zip" -$Url = "https://github.com/$Repo/releases/download/$Tag/$Filename" +$Url = "$CdnBase/v$Version/$Filename" $TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("browseros-cli-install-" + [System.IO.Path]::GetRandomFileName()) try { diff --git a/packages/browseros-agent/apps/cli/scripts/install.sh b/packages/browseros-agent/apps/cli/scripts/install.sh index fd07bdeaa..f181876ba 100755 --- a/packages/browseros-agent/apps/cli/scripts/install.sh +++ b/packages/browseros-agent/apps/cli/scripts/install.sh @@ -10,7 +10,7 @@ set -euo pipefail -REPO="browseros-ai/BrowserOS" +CDN_BASE="https://cdn.browseros.com/cli" BINARY="browseros-cli" INSTALL_DIR="${HOME}/.browseros/bin" @@ -43,13 +43,7 @@ done # ── Resolve latest version ─────────────────────────────────────────────────── if [[ -z "$VERSION" ]]; then - # Use per_page=1 with a tag name filter via the releases endpoint. - # The tags all start with "browseros-cli-v" so we grab page 1 of those. - VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" \ - | grep -o '"tag_name": *"browseros-cli-v[^"]*"' \ - | grep -v -- "-rc" \ - | head -1 \ - | sed 's/.*browseros-cli-v//; s/"//') + VERSION=$(curl -fsSL "${CDN_BASE}/latest/version.txt" | tr -d '[:space:]') if [[ -z "$VERSION" ]]; then echo "Error: could not determine latest version." >&2 @@ -58,6 +52,11 @@ if [[ -z "$VERSION" ]]; then fi fi +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "Error: unexpected version format: '$VERSION'" >&2 + exit 1 +fi + echo "Installing browseros-cli v${VERSION}..." # ── Detect platform ────────────────────────────────────────────────────────── @@ -80,9 +79,8 @@ esac # ── Download and extract ───────────────────────────────────────────────────── FILENAME="${BINARY}_${VERSION}_${OS}_${ARCH}.tar.gz" -TAG="browseros-cli-v${VERSION}" -URL="https://github.com/${REPO}/releases/download/${TAG}/${FILENAME}" -CHECKSUM_URL="https://github.com/${REPO}/releases/download/${TAG}/checksums.txt" +URL="${CDN_BASE}/v${VERSION}/${FILENAME}" +CHECKSUM_URL="${CDN_BASE}/v${VERSION}/checksums.txt" TMPDIR_DL=$(mktemp -d) trap 'rm -rf "$TMPDIR_DL"' EXIT diff --git a/packages/browseros-agent/scripts/build/cli.ts b/packages/browseros-agent/scripts/build/cli.ts index 8512a3197..bd305f1d3 100644 --- a/packages/browseros-agent/scripts/build/cli.ts +++ b/packages/browseros-agent/scripts/build/cli.ts @@ -1,8 +1,46 @@ #!/usr/bin/env bun -import { runCliInstallerUpload } from './cli/upload' +import { Command } from 'commander' -runCliInstallerUpload().catch((error) => { +import { runCliInstallerUpload, runCliRelease } from './cli/upload' + +const program = new Command('cli-upload') + .description('Upload BrowserOS CLI artifacts to CDN') + .option( + '--release', + 'Upload full release (binaries + installers + version.txt)', + ) + .option('--version ', 'Release version (required with --release)') + .option( + '--binaries-dir ', + 'Directory containing built archives (required with --release)', + ) + .parse() + +const opts = program.opts<{ + release?: boolean + version?: string + binariesDir?: string +}>() + +async function main(): Promise { + if (opts.release) { + if (!opts.version) { + throw new Error('--version is required with --release') + } + if (!opts.binariesDir) { + throw new Error('--binaries-dir is required with --release') + } + await runCliRelease({ + version: opts.version, + binariesDir: opts.binariesDir, + }) + } else { + await runCliInstallerUpload() + } +} + +main().catch((error) => { const message = error instanceof Error ? error.message : String(error) console.error(`\n✗ ${message}\n`) process.exit(1) diff --git a/packages/browseros-agent/scripts/build/cli/config.ts b/packages/browseros-agent/scripts/build/cli/config.ts index 010ca179d..04bbb4ee5 100644 --- a/packages/browseros-agent/scripts/build/cli/config.ts +++ b/packages/browseros-agent/scripts/build/cli/config.ts @@ -6,7 +6,6 @@ import { parse } from 'dotenv' import type { R2Config } from '../server/types' const PROD_ENV_PATH = join('apps', 'cli', '.env.production') -const PROD_ENV_TEMPLATE_PATH = join('apps', 'cli', '.env.production.example') function pickEnv(name: string, fileEnv: Record): string { const value = process.env[name] ?? fileEnv[name] @@ -19,15 +18,8 @@ function pickEnv(name: string, fileEnv: Record): string { function loadProdEnv(rootDir: string): Record { const prodEnvPath = join(rootDir, PROD_ENV_PATH) if (!existsSync(prodEnvPath)) { - const templatePath = join(rootDir, PROD_ENV_TEMPLATE_PATH) - if (existsSync(templatePath)) { - throw new Error( - `Missing ${PROD_ENV_PATH}. Create it from ${PROD_ENV_TEMPLATE_PATH} before running upload:cli-installers.`, - ) - } - throw new Error( - `Missing ${PROD_ENV_PATH}. The template file ${PROD_ENV_TEMPLATE_PATH} was not found.`, - ) + // In CI, credentials come from process.env — no .env file needed + return {} } return parse(readFileSync(prodEnvPath, 'utf-8')) } diff --git a/packages/browseros-agent/scripts/build/cli/upload.ts b/packages/browseros-agent/scripts/build/cli/upload.ts index d436d14a7..abd2edf00 100644 --- a/packages/browseros-agent/scripts/build/cli/upload.ts +++ b/packages/browseros-agent/scripts/build/cli/upload.ts @@ -1,4 +1,6 @@ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } 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' @@ -21,10 +23,23 @@ const INSTALLERS = [ }, ] as const -export async function runCliInstallerUpload(): Promise { +export interface CliReleaseOptions { + version: string + binariesDir: string +} + +function resolveRootDir(): string { const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') process.chdir(rootDir) - await uploadCliInstallers(rootDir) + return rootDir +} + +export async function runCliInstallerUpload(): Promise { + await uploadCliInstallers(resolveRootDir()) +} + +export async function runCliRelease(options: CliReleaseOptions): Promise { + await uploadCliRelease(resolveRootDir(), options) } export async function uploadCliInstallers(rootDir: string): Promise { @@ -54,3 +69,60 @@ export async function uploadCliInstallers(rootDir: string): Promise { client.destroy() } } + +async function uploadCliRelease( + rootDir: string, + options: CliReleaseOptions, +): Promise { + 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 { 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}`) + } + + 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) +}