mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
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:
24
.github/workflows/release-cli.yml
vendored
24
.github/workflows/release-cli.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user