This commit is contained in:
Nathan Flurry 2026-01-27 13:56:09 -08:00
parent 34d4f3693e
commit 29b159ca20
28 changed files with 2138 additions and 395 deletions

View file

@ -0,0 +1,117 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $ } from "execa";
import type { ReleaseOpts } from "./main.js";
import {
assertDirExists,
copyReleasesPath,
deleteReleasesPath,
listReleasesObjects,
uploadContentToReleases,
uploadDirToReleases,
uploadFileToReleases,
} from "./utils.js";
const PREFIX = "sandbox-agent";
const BINARY_FILES = [
"sandbox-agent-x86_64-unknown-linux-musl",
"sandbox-agent-x86_64-pc-windows-gnu.exe",
"sandbox-agent-x86_64-apple-darwin",
"sandbox-agent-aarch64-apple-darwin",
];
/**
* Build TypeScript SDK and upload to commit directory.
* This is called during setup-ci phase.
*/
export async function buildAndUploadArtifacts(opts: ReleaseOpts) {
console.log("==> Building TypeScript SDK");
const sdkDir = path.join(opts.root, "sdks", "typescript");
await $({ stdio: "inherit", cwd: sdkDir })`pnpm install`;
await $({ stdio: "inherit", cwd: sdkDir })`pnpm run build`;
const distPath = path.join(sdkDir, "dist");
await assertDirExists(distPath);
console.log(`==> Uploading TypeScript SDK to ${PREFIX}/${opts.commit}/typescript/`);
await uploadDirToReleases(distPath, `${PREFIX}/${opts.commit}/typescript/`);
console.log("✅ TypeScript SDK artifacts uploaded");
}
/**
* Promote artifacts from commit directory to version directory.
* This is called during complete-ci phase.
*/
export async function promoteArtifacts(opts: ReleaseOpts) {
// Promote TypeScript SDK
await promotePath(opts, "typescript");
}
async function promotePath(opts: ReleaseOpts, name: string) {
console.log(`==> Promoting ${name} artifacts`);
const sourcePrefix = `${PREFIX}/${opts.commit}/${name}/`;
const commitFiles = await listReleasesObjects(sourcePrefix);
if (!Array.isArray(commitFiles?.Contents) || commitFiles.Contents.length === 0) {
throw new Error(`No files found under ${sourcePrefix}`);
}
await copyPath(sourcePrefix, `${PREFIX}/${opts.version}/${name}/`);
if (opts.latest) {
await copyPath(sourcePrefix, `${PREFIX}/latest/${name}/`);
}
}
async function copyPath(sourcePrefix: string, targetPrefix: string) {
console.log(`Copying ${sourcePrefix} -> ${targetPrefix}`);
await deleteReleasesPath(targetPrefix);
await copyReleasesPath(sourcePrefix, targetPrefix);
}
/**
* Upload install script with version substitution.
*/
export async function uploadInstallScripts(opts: ReleaseOpts) {
const installPath = path.join(opts.root, "scripts", "release", "static", "install.sh");
let installContent = await fs.readFile(installPath, "utf8");
const uploadForVersion = async (versionValue: string, remoteVersion: string) => {
const content = installContent.replace(/__VERSION__/g, versionValue);
const uploadKey = `${PREFIX}/${remoteVersion}/install.sh`;
console.log(`Uploading install script: ${uploadKey}`);
await uploadContentToReleases(content, uploadKey);
};
await uploadForVersion(opts.version, opts.version);
if (opts.latest) {
await uploadForVersion("latest", "latest");
}
}
/**
* Upload compiled binaries from dist/ directory.
*/
export async function uploadBinaries(opts: ReleaseOpts) {
const distDir = path.join(opts.root, "dist");
await assertDirExists(distDir);
for (const fileName of BINARY_FILES) {
const localPath = path.join(distDir, fileName);
try {
await fs.access(localPath);
} catch {
throw new Error(`Missing binary: ${localPath}`);
}
console.log(`Uploading binary: ${fileName}`);
await uploadFileToReleases(localPath, `${PREFIX}/${opts.version}/${fileName}`);
if (opts.latest) {
await uploadFileToReleases(localPath, `${PREFIX}/latest/${fileName}`);
}
}
console.log("✅ Binaries uploaded");
}

74
scripts/release/git.ts Normal file
View file

@ -0,0 +1,74 @@
import { $ } from "execa";
import * as semver from "semver";
import type { ReleaseOpts } from "./main.js";
export async function validateGit(_opts: ReleaseOpts) {
const result = await $`git status --porcelain`;
const status = result.stdout;
if (status.trim().length > 0) {
throw new Error(
"There are uncommitted changes. Please commit or stash them.",
);
}
}
export async function createAndPushTag(opts: ReleaseOpts) {
console.log(`Creating tag v${opts.version}...`);
try {
await $({ stdio: "inherit", cwd: opts.root })`git tag -f v${opts.version}`;
await $({ stdio: "inherit", cwd: opts.root })`git push origin v${opts.version} -f`;
console.log(`✅ Tag v${opts.version} created and pushed`);
} catch (err) {
console.error("❌ Failed to create or push tag");
throw err;
}
}
export async function createGitHubRelease(opts: ReleaseOpts) {
console.log("Creating GitHub release...");
try {
console.log(`Looking for existing release for ${opts.version}`);
const { stdout: releaseJson } = await $({
cwd: opts.root,
})`gh release list --json name,tagName`;
const releases = JSON.parse(releaseJson);
const existingRelease = releases.find(
(r: { name: string }) => r.name === opts.version,
);
if (existingRelease) {
console.log(
`Updating release ${opts.version} to point to tag v${opts.version}`,
);
await $({
stdio: "inherit",
cwd: opts.root,
})`gh release edit ${existingRelease.tagName} --tag v${opts.version}`;
} else {
console.log(
`Creating new release ${opts.version} pointing to tag v${opts.version}`,
);
await $({
stdio: "inherit",
cwd: opts.root,
})`gh release create v${opts.version} --title ${opts.version} --generate-notes`;
// Mark as prerelease if needed
const parsed = semver.parse(opts.version);
if (parsed && parsed.prerelease.length > 0) {
await $({
stdio: "inherit",
cwd: opts.root,
})`gh release edit v${opts.version} --prerelease`;
}
}
console.log("✅ GitHub release created/updated");
} catch (err) {
console.error("❌ Failed to create GitHub release");
console.warn("! You may need to create the release manually");
throw err;
}
}

View file

@ -47,6 +47,8 @@ const STEPS = [
"upload-typescript",
"upload-install",
"upload-binaries",
"push-tag",
"create-github-release",
] as const;
const PHASES = ["setup-local", "setup-ci", "complete-ci"] as const;
@ -71,6 +73,8 @@ const PHASE_MAP: Record<Phase, Step[]> = {
"upload-typescript",
"upload-install",
"upload-binaries",
"push-tag",
"create-github-release",
],
};
@ -207,6 +211,79 @@ function shouldTagAsLatest(version: string) {
return compareSemver(parsed, parseSemver(latestStable)) > 0;
}
function npmVersionExists(packageName: string, version: string): boolean {
const result = spawnSync("npm", ["view", `${packageName}@${version}`, "version"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
if (result.status === 0) {
return true;
}
const stderr = result.stderr || "";
if (
stderr.includes(`No match found for version ${version}`) ||
stderr.includes(`'${packageName}@${version}' is not in this registry`)
) {
return false;
}
// If it's an unexpected error, assume version doesn't exist to allow publish attempt
return false;
}
function crateVersionExists(crateName: string, version: string): boolean {
const result = spawnSync("cargo", ["search", crateName, "--limit", "1"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
if (result.status !== 0) {
return false;
}
// Output format: "crate_name = \"version\""
const output = result.stdout || "";
const match = output.match(new RegExp(`^${crateName}\\s*=\\s*"([^"]+)"`));
if (match && match[1] === version) {
return true;
}
return false;
}
function createAndPushTag(rootDir: string, version: string) {
console.log(`==> Creating tag v${version}`);
run("git", ["tag", "-f", `v${version}`], { cwd: rootDir });
run("git", ["push", "origin", `v${version}`, "-f"], { cwd: rootDir });
console.log(`Tag v${version} created and pushed`);
}
function createGitHubRelease(rootDir: string, version: string) {
console.log(`==> Creating GitHub release for v${version}`);
// Check if release already exists
const listResult = spawnSync("gh", ["release", "list", "--json", "tagName"], {
cwd: rootDir,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
if (listResult.status === 0) {
const releases = JSON.parse(listResult.stdout || "[]");
const exists = releases.some((r: { tagName: string }) => r.tagName === `v${version}`);
if (exists) {
console.log(`Release v${version} already exists, updating...`);
run("gh", ["release", "edit", `v${version}`, "--tag", `v${version}`], { cwd: rootDir });
return;
}
}
// Create new release
const isPrerelease = parseSemver(version).prerelease.length > 0;
const releaseArgs = ["release", "create", `v${version}`, "--title", version, "--generate-notes"];
if (isPrerelease) {
releaseArgs.push("--prerelease");
}
run("gh", releaseArgs, { cwd: rootDir });
console.log(`GitHub release v${version} created`);
}
function getAwsEnv() {
const accessKey =
process.env.AWS_ACCESS_KEY_ID || process.env.R2_RELEASES_ACCESS_KEY_ID;
@ -416,7 +493,12 @@ function publishCrates(rootDir: string, version: string) {
updateVersion(rootDir, version);
for (const crate of CRATE_ORDER) {
console.log(`==> Publishing sandbox-agent-${crate}`);
const crateName = `sandbox-agent-${crate}`;
if (crateVersionExists(crateName, version)) {
console.log(`==> Skipping ${crateName}@${version} (already published)`);
continue;
}
console.log(`==> Publishing ${crateName}`);
const crateDir = path.join(rootDir, "server", "packages", crate);
run("cargo", ["publish", "--allow-dirty"], { cwd: crateDir });
console.log("Waiting 30s for index...");
@ -426,7 +508,14 @@ function publishCrates(rootDir: string, version: string) {
function publishNpmSdk(rootDir: string, version: string, latest: boolean) {
const sdkDir = path.join(rootDir, "sdks", "typescript");
console.log("==> Publishing TypeScript SDK to npm");
const packageName = "sandbox-agent";
if (npmVersionExists(packageName, version)) {
console.log(`==> Skipping ${packageName}@${version} (already published)`);
return;
}
console.log(`==> Publishing ${packageName}@${version} to npm`);
const npmTag = getNpmTag(version, latest);
run("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"], { cwd: sdkDir });
run("pnpm", ["install"], { cwd: sdkDir });
@ -442,6 +531,13 @@ function publishNpmCli(rootDir: string, version: string, latest: boolean) {
const npmTag = getNpmTag(version, latest);
for (const [target, info] of Object.entries(PLATFORM_MAP)) {
const packageName = `@sandbox-agent/cli-${info.pkg}`;
if (npmVersionExists(packageName, version)) {
console.log(`==> Skipping ${packageName}@${version} (already published)`);
continue;
}
const platformDir = path.join(cliDir, "platforms", info.pkg);
const binDir = path.join(platformDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
@ -451,14 +547,20 @@ function publishNpmCli(rootDir: string, version: string, latest: boolean) {
fs.copyFileSync(srcBinary, dstBinary);
if (info.ext !== ".exe") fs.chmodSync(dstBinary, 0o755);
console.log(`==> Publishing @sandbox-agent/cli-${info.pkg}`);
console.log(`==> Publishing ${packageName}@${version}`);
run("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"], { cwd: platformDir });
const publishArgs = ["publish", "--access", "public"];
if (npmTag) publishArgs.push("--tag", npmTag);
run("npm", publishArgs, { cwd: platformDir });
}
console.log("==> Publishing @sandbox-agent/cli");
const mainPackageName = "@sandbox-agent/cli";
if (npmVersionExists(mainPackageName, version)) {
console.log(`==> Skipping ${mainPackageName}@${version} (already published)`);
return;
}
console.log(`==> Publishing ${mainPackageName}@${version}`);
const pkgPath = path.join(cliDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.version = version;
@ -617,26 +719,26 @@ async function main() {
}
}
if (shouldRun("trigger-workflow")) {
console.log("==> Triggering release workflow");
const branch = runCapture("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: rootDir });
const latestFlag = latest ? "true" : "false";
run(
"gh",
[
"workflow",
"run",
".github/workflows/release.yaml",
"-f",
`version=${version}`,
"-f",
`latest=${latestFlag}`,
"--ref",
branch,
],
{ cwd: rootDir },
);
}
// if (shouldRun("trigger-workflow")) {
// console.log("==> Triggering release workflow");
// const branch = runCapture("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: rootDir });
// const latestFlag = latest ? "true" : "false";
// run(
// "gh",
// [
// "workflow",
// "run",
// ".github/workflows/release.yaml",
// "-f",
// `version=${version}`,
// "-f",
// `latest=${latestFlag}`,
// "--ref",
// branch,
// ],
// { cwd: rootDir },
// );
// }
if (shouldRun("run-checks")) {
runChecks(rootDir);
@ -665,6 +767,14 @@ async function main() {
if (shouldRun("upload-binaries")) {
uploadBinaries(rootDir, version, latest);
}
if (shouldRun("push-tag")) {
createAndPushTag(rootDir, version);
}
if (shouldRun("create-github-release")) {
createGitHubRelease(rootDir, version);
}
}
main().catch((err) => {

View file

@ -0,0 +1,19 @@
{
"name": "release",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/semver": "^7.5.8"
},
"dependencies": {
"commander": "^12.1.0",
"execa": "^9.5.0",
"glob": "^10.3.10",
"semver": "^7.6.0"
}
}

166
scripts/release/publish.ts Normal file
View file

@ -0,0 +1,166 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $ } from "execa";
import * as semver from "semver";
import type { ReleaseOpts } from "./main.js";
const CRATE_ORDER = [
"error",
"agent-credentials",
"agent-schema",
"universal-agent-schema",
"agent-management",
"sandbox-agent",
];
const PLATFORM_MAP: Record<string, { pkg: string; os: string; cpu: string; ext: string }> = {
"x86_64-unknown-linux-musl": { pkg: "linux-x64", os: "linux", cpu: "x64", ext: "" },
"x86_64-pc-windows-gnu": { pkg: "win32-x64", os: "win32", cpu: "x64", ext: ".exe" },
"x86_64-apple-darwin": { pkg: "darwin-x64", os: "darwin", cpu: "x64", ext: "" },
"aarch64-apple-darwin": { pkg: "darwin-arm64", os: "darwin", cpu: "arm64", ext: "" },
};
async function npmVersionExists(packageName: string, version: string): Promise<boolean> {
console.log(`Checking if ${packageName}@${version} exists on npm...`);
try {
await $({
stdout: "ignore",
stderr: "pipe",
})`npm view ${packageName}@${version} version`;
return true;
} catch (error: unknown) {
const stderr = error && typeof error === "object" && "stderr" in error
? String(error.stderr)
: "";
if (
stderr.includes(`No match found for version ${version}`) ||
stderr.includes(`'${packageName}@${version}' is not in this registry`)
) {
return false;
}
// Unexpected error, assume not exists to allow publish attempt
return false;
}
}
async function crateVersionExists(crateName: string, version: string): Promise<boolean> {
console.log(`Checking if ${crateName}@${version} exists on crates.io...`);
try {
const result = await $`cargo search ${crateName} --limit 1`;
const output = result.stdout || "";
const match = output.match(new RegExp(`^${crateName}\\s*=\\s*"([^"]+)"`));
return !!(match && match[1] === version);
} catch {
return false;
}
}
function getNpmTag(version: string, latest: boolean): string | null {
if (latest) return null;
const parsed = semver.parse(version);
if (!parsed) throw new Error(`Invalid version: ${version}`);
if (parsed.prerelease.length === 0) {
return "next";
}
const hasRc = parsed.prerelease.some((part) =>
String(part).toLowerCase().startsWith("rc")
);
if (hasRc) {
return "rc";
}
throw new Error(`Prerelease versions must use rc tag when not latest: ${version}`);
}
export async function publishCrates(opts: ReleaseOpts) {
for (const crate of CRATE_ORDER) {
const crateName = `sandbox-agent-${crate}`;
if (await crateVersionExists(crateName, opts.version)) {
console.log(`==> Skipping ${crateName}@${opts.version} (already published)`);
continue;
}
console.log(`==> Publishing ${crateName}@${opts.version}`);
const crateDir = path.join(opts.root, "server", "packages", crate);
await $({ stdio: "inherit", cwd: crateDir })`cargo publish --allow-dirty`;
console.log("Waiting 30s for crates.io index...");
await new Promise(resolve => setTimeout(resolve, 30000));
}
}
export async function publishNpmSdk(opts: ReleaseOpts) {
const sdkDir = path.join(opts.root, "sdks", "typescript");
const packageName = "sandbox-agent";
if (await npmVersionExists(packageName, opts.version)) {
console.log(`==> Skipping ${packageName}@${opts.version} (already published)`);
return;
}
console.log(`==> Publishing ${packageName}@${opts.version}`);
const npmTag = getNpmTag(opts.version, opts.latest);
await $({ stdio: "inherit", cwd: sdkDir })`npm version ${opts.version} --no-git-tag-version --allow-same-version`;
await $({ stdio: "inherit", cwd: sdkDir })`pnpm install`;
await $({ stdio: "inherit", cwd: sdkDir })`pnpm run build`;
const publishArgs = ["publish", "--access", "public"];
if (npmTag) publishArgs.push("--tag", npmTag);
await $({ stdio: "inherit", cwd: sdkDir })`npm ${publishArgs}`;
}
export async function publishNpmCli(opts: ReleaseOpts) {
const cliDir = path.join(opts.root, "sdks", "cli");
const distDir = path.join(opts.root, "dist");
const npmTag = getNpmTag(opts.version, opts.latest);
// Publish platform-specific packages
for (const [target, info] of Object.entries(PLATFORM_MAP)) {
const packageName = `@sandbox-agent/cli-${info.pkg}`;
if (await npmVersionExists(packageName, opts.version)) {
console.log(`==> Skipping ${packageName}@${opts.version} (already published)`);
continue;
}
const platformDir = path.join(cliDir, "platforms", info.pkg);
const binDir = path.join(platformDir, "bin");
await fs.mkdir(binDir, { recursive: true });
const srcBinary = path.join(distDir, `sandbox-agent-${target}${info.ext}`);
const dstBinary = path.join(binDir, `sandbox-agent${info.ext}`);
await fs.copyFile(srcBinary, dstBinary);
if (info.ext !== ".exe") {
await fs.chmod(dstBinary, 0o755);
}
console.log(`==> Publishing ${packageName}@${opts.version}`);
await $({ stdio: "inherit", cwd: platformDir })`npm version ${opts.version} --no-git-tag-version --allow-same-version`;
const publishArgs = ["publish", "--access", "public"];
if (npmTag) publishArgs.push("--tag", npmTag);
await $({ stdio: "inherit", cwd: platformDir })`npm ${publishArgs}`;
}
// Publish main CLI package
const mainPackageName = "@sandbox-agent/cli";
if (await npmVersionExists(mainPackageName, opts.version)) {
console.log(`==> Skipping ${mainPackageName}@${opts.version} (already published)`);
return;
}
console.log(`==> Publishing ${mainPackageName}@${opts.version}`);
const pkgPath = path.join(cliDir, "package.json");
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"));
pkg.version = opts.version;
for (const dep of Object.keys(pkg.optionalDependencies || {})) {
pkg.optionalDependencies[dep] = opts.version;
}
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
const publishArgs = ["publish", "--access", "public"];
if (npmTag) publishArgs.push("--tag", npmTag);
await $({ stdio: "inherit", cwd: cliDir })`npm ${publishArgs}`;
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["*.ts"]
}

View file

@ -0,0 +1,68 @@
import * as fs from "node:fs/promises";
import { glob } from "glob";
import { $ } from "execa";
import type { ReleaseOpts } from "./main.js";
import { assert } from "./utils.js";
export async function updateVersion(opts: ReleaseOpts) {
const findReplace = [
{
path: "Cargo.toml",
find: /^version = ".*"/m,
replace: `version = "${opts.version}"`,
},
{
path: "sdks/typescript/package.json",
find: /"version": ".*"/,
replace: `"version": "${opts.version}"`,
},
{
path: "sdks/cli/package.json",
find: /"version": ".*"/,
replace: `"version": "${opts.version}"`,
},
{
path: "sdks/cli/platforms/*/package.json",
find: /"version": ".*"/,
replace: `"version": "${opts.version}"`,
},
];
for (const { path: globPath, find, replace } of findReplace) {
const paths = await glob(globPath, { cwd: opts.root });
assert(paths.length > 0, `no paths matched: ${globPath}`);
for (const filePath of paths) {
const fullPath = `${opts.root}/${filePath}`;
const file = await fs.readFile(fullPath, "utf-8");
assert(find.test(file), `file does not match ${find}: ${filePath}`);
const newFile = file.replace(find, replace);
await fs.writeFile(fullPath, newFile);
await $({ cwd: opts.root })`git add ${filePath}`;
}
}
// Update optionalDependencies in CLI package.json
const cliPkgPath = `${opts.root}/sdks/cli/package.json`;
const cliPkg = JSON.parse(await fs.readFile(cliPkgPath, "utf-8"));
if (cliPkg.optionalDependencies) {
for (const dep of Object.keys(cliPkg.optionalDependencies)) {
cliPkg.optionalDependencies[dep] = opts.version;
}
await fs.writeFile(cliPkgPath, JSON.stringify(cliPkg, null, 2) + "\n");
await $({ cwd: opts.root })`git add sdks/cli/package.json`;
}
// Update optionalDependencies in TypeScript SDK package.json
const sdkPkgPath = `${opts.root}/sdks/typescript/package.json`;
const sdkPkg = JSON.parse(await fs.readFile(sdkPkgPath, "utf-8"));
if (sdkPkg.optionalDependencies) {
for (const dep of Object.keys(sdkPkg.optionalDependencies)) {
sdkPkg.optionalDependencies[dep] = opts.version;
}
await fs.writeFile(sdkPkgPath, JSON.stringify(sdkPkg, null, 2) + "\n");
await $({ cwd: opts.root })`git add sdks/typescript/package.json`;
}
}

175
scripts/release/utils.ts Normal file
View file

@ -0,0 +1,175 @@
import * as fs from "node:fs/promises";
import { $ } from "execa";
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
export function assertEquals<T>(actual: T, expected: T, message?: string): void {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
}
export function assertExists<T>(
value: T | null | undefined,
message?: string,
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(message || "Value does not exist");
}
}
export async function assertDirExists(dirPath: string): Promise<void> {
try {
const stat = await fs.stat(dirPath);
if (!stat.isDirectory()) {
throw new Error(`Path exists but is not a directory: ${dirPath}`);
}
} catch (err: unknown) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
throw new Error(`Directory not found: ${dirPath}`);
}
throw err;
}
}
// R2 configuration
const ENDPOINT_URL = "https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com";
const BUCKET = "rivet-releases";
interface ReleasesS3Config {
awsEnv: Record<string, string>;
endpointUrl: string;
}
let cachedConfig: ReleasesS3Config | null = null;
export async function getReleasesS3Config(): Promise<ReleasesS3Config> {
if (cachedConfig) {
return cachedConfig;
}
let awsAccessKeyId = process.env.R2_RELEASES_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
let awsSecretAccessKey = process.env.R2_RELEASES_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
// Try 1Password fallback for local development
if (!awsAccessKeyId) {
try {
const result = await $`op read ${"op://Engineering/rivet-releases R2 Upload/username"}`;
awsAccessKeyId = result.stdout.trim();
} catch {
// 1Password not available
}
}
if (!awsSecretAccessKey) {
try {
const result = await $`op read ${"op://Engineering/rivet-releases R2 Upload/password"}`;
awsSecretAccessKey = result.stdout.trim();
} catch {
// 1Password not available
}
}
assert(awsAccessKeyId, "R2_RELEASES_ACCESS_KEY_ID is required");
assert(awsSecretAccessKey, "R2_RELEASES_SECRET_ACCESS_KEY is required");
cachedConfig = {
awsEnv: {
AWS_ACCESS_KEY_ID: awsAccessKeyId,
AWS_SECRET_ACCESS_KEY: awsSecretAccessKey,
AWS_DEFAULT_REGION: "auto",
},
endpointUrl: ENDPOINT_URL,
};
return cachedConfig;
}
export async function uploadFileToReleases(
localPath: string,
remotePath: string,
): Promise<void> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
await $({
env: awsEnv,
stdio: "inherit",
})`aws s3 cp ${localPath} s3://${BUCKET}/${remotePath} --checksum-algorithm CRC32 --endpoint-url ${endpointUrl}`;
}
export async function uploadDirToReleases(
localPath: string,
remotePath: string,
): Promise<void> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
await $({
env: awsEnv,
stdio: "inherit",
})`aws s3 cp ${localPath} s3://${BUCKET}/${remotePath} --recursive --checksum-algorithm CRC32 --endpoint-url ${endpointUrl}`;
}
export async function uploadContentToReleases(
content: string,
remotePath: string,
): Promise<void> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
await $({
env: awsEnv,
input: content,
stdio: ["pipe", "inherit", "inherit"],
})`aws s3 cp - s3://${BUCKET}/${remotePath} --endpoint-url ${endpointUrl}`;
}
export interface ListReleasesResult {
Contents?: { Key: string; Size: number }[];
}
export async function listReleasesObjects(
prefix: string,
): Promise<ListReleasesResult> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
const result = await $({
env: awsEnv,
stdio: ["pipe", "pipe", "inherit"],
})`aws s3api list-objects --bucket ${BUCKET} --prefix ${prefix} --endpoint-url ${endpointUrl}`;
return JSON.parse(result.stdout);
}
export async function deleteReleasesPath(remotePath: string): Promise<void> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
await $({
env: awsEnv,
stdio: "inherit",
})`aws s3 rm s3://${BUCKET}/${remotePath} --recursive --endpoint-url ${endpointUrl}`;
}
/**
* Copies objects from one S3 path to another within the releases bucket.
* Uses s3api copy-object to avoid R2 tagging header issues.
*/
export async function copyReleasesPath(
sourcePath: string,
targetPath: string,
): Promise<void> {
const { awsEnv, endpointUrl } = await getReleasesS3Config();
const listResult = await $({
env: awsEnv,
})`aws s3api list-objects --bucket ${BUCKET} --prefix ${sourcePath} --endpoint-url ${endpointUrl}`;
const objects = JSON.parse(listResult.stdout);
if (!objects.Contents?.length) {
throw new Error(`No objects found under ${sourcePath}`);
}
for (const obj of objects.Contents) {
const sourceKey = obj.Key;
const targetKey = sourceKey.replace(sourcePath, targetPath);
console.log(` ${sourceKey} -> ${targetKey}`);
await $({
env: awsEnv,
})`aws s3api copy-object --bucket ${BUCKET} --key ${targetKey} --copy-source ${BUCKET}/${sourceKey} --endpoint-url ${endpointUrl}`;
}
}