feat: upload CLI binaries to CDN and gate release to core team (#602)

* 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
This commit is contained in:
Nikhil
2026-03-27 11:47:31 -07:00
committed by GitHub
parent 1c5ffdf878
commit 6c053a5f29
6 changed files with 160 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -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 <version>', 'Release version (required with --release)')
.option(
'--binaries-dir <dir>',
'Directory containing built archives (required with --release)',
)
.parse()
const opts = program.opts<{
release?: boolean
version?: string
binariesDir?: string
}>()
async function main(): Promise<void> {
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)

View File

@@ -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, string>): string {
const value = process.env[name] ?? fileEnv[name]
@@ -19,15 +18,8 @@ function pickEnv(name: string, fileEnv: Record<string, string>): string {
function loadProdEnv(rootDir: string): Record<string, string> {
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'))
}

View File

@@ -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<void> {
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<void> {
await uploadCliInstallers(resolveRootDir())
}
export async function runCliRelease(options: CliReleaseOptions): Promise<void> {
await uploadCliRelease(resolveRootDir(), options)
}
export async function uploadCliInstallers(rootDir: string): Promise<void> {
@@ -54,3 +69,60 @@ export async function uploadCliInstallers(rootDir: string): Promise<void> {
client.destroy()
}
}
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 { 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)
}