mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 16:04:06 +00:00
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
595 lines
19 KiB
TypeScript
Executable file
595 lines
19 KiB
TypeScript
Executable file
#!/usr/bin/env tsx
|
|
|
|
import * as path from "node:path";
|
|
import * as url from "node:url";
|
|
import { $ } from "execa";
|
|
import { program } from "commander";
|
|
import * as semver from "semver";
|
|
import { buildJsArtifacts } from "./build-artifacts";
|
|
import { promoteArtifacts } from "./promote-artifacts";
|
|
import { tagDocker } from "./docker";
|
|
import { createAndPushTag, createGitHubRelease, validateGit } from "./git";
|
|
import { publishCrates, publishNpmCli, publishNpmLibraries } from "./sdk";
|
|
import { updateVersion } from "./update_version";
|
|
import { assert, assertEquals, fetchGitRef, versionOrCommitToRef } from "./utils";
|
|
|
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const ROOT_DIR = path.resolve(__dirname, "..", "..");
|
|
|
|
export interface ReleaseOpts {
|
|
root: string;
|
|
version: string;
|
|
latest: boolean;
|
|
minorVersionChannel: string;
|
|
/** Commit to publish release for. */
|
|
commit: string;
|
|
/** Optional version to reuse artifacts and Docker images from instead of building. */
|
|
reuseEngineVersion?: string;
|
|
}
|
|
|
|
async function getAllGitVersions(): Promise<string[]> {
|
|
try {
|
|
// Fetch tags to ensure we have the latest
|
|
// Use --force to overwrite local tags that conflict with remote
|
|
try {
|
|
await $`git fetch --tags --force --quiet`;
|
|
} catch (fetchError) {
|
|
console.warn("Warning: Could not fetch remote tags, using local tags only");
|
|
}
|
|
|
|
// Get all version tags
|
|
const result = await $`git tag -l v*`;
|
|
const tags = result.stdout.trim().split("\n").filter(Boolean);
|
|
|
|
if (tags.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Parse and sort all versions (newest first)
|
|
const versions = tags
|
|
.map((tag) => tag.replace(/^v/, ""))
|
|
.filter((v) => semver.valid(v))
|
|
.sort((a, b) => semver.rcompare(a, b));
|
|
|
|
return versions;
|
|
} catch (error) {
|
|
console.warn("Warning: Could not get git tags:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getLatestGitVersion(): Promise<string | null> {
|
|
const versions = await getAllGitVersions();
|
|
|
|
if (versions.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Find the latest version (excluding prereleases)
|
|
const stableVersions = versions.filter((v) => {
|
|
const parsed = semver.parse(v);
|
|
return parsed && parsed.prerelease.length === 0;
|
|
});
|
|
|
|
return stableVersions[0] || null;
|
|
}
|
|
|
|
async function shouldTagAsLatest(newVersion: string): Promise<boolean> {
|
|
// Check if version has prerelease identifier
|
|
const parsedVersion = semver.parse(newVersion);
|
|
if (!parsedVersion) {
|
|
throw new Error(`Invalid semantic version: ${newVersion}`);
|
|
}
|
|
|
|
// If it has a prerelease identifier, it's not latest
|
|
if (parsedVersion.prerelease.length > 0) {
|
|
return false;
|
|
}
|
|
|
|
// Get the latest version from git tags
|
|
const latestGitVersion = await getLatestGitVersion();
|
|
|
|
// If no previous versions exist, this is the latest
|
|
if (!latestGitVersion) {
|
|
return true;
|
|
}
|
|
|
|
// Check if new version is greater than the latest git version
|
|
return semver.gt(newVersion, latestGitVersion);
|
|
}
|
|
|
|
async function validateReuseVersion(version: string): Promise<void> {
|
|
console.log(`Validating that ${version} exists...`);
|
|
|
|
const ref = versionOrCommitToRef(version);
|
|
await fetchGitRef(ref);
|
|
|
|
// Get short commit from ref
|
|
let shortCommit: string;
|
|
try {
|
|
const result = await $`git rev-parse ${ref}`;
|
|
const fullCommit = result.stdout.trim();
|
|
shortCommit = fullCommit.slice(0, 7);
|
|
console.log(`✅ Found ${ref} (commit ${shortCommit})`);
|
|
} catch (error) {
|
|
throw new Error(`${version} does not exist in git. Make sure ${ref} exists in the repository.`);
|
|
}
|
|
|
|
// Check Docker images exist (optional - warn if not found)
|
|
console.log(`Checking Docker images for ${shortCommit}...`);
|
|
try {
|
|
await $({ stdio: "inherit" })`docker manifest inspect rivetdev/sandbox-agent:${shortCommit}-amd64`;
|
|
await $({ stdio: "inherit" })`docker manifest inspect rivetdev/sandbox-agent:${shortCommit}-arm64`;
|
|
console.log("✅ Docker images exist");
|
|
} catch (error) {
|
|
console.log(`⚠️ Docker images for ${shortCommit} not found - skipping Docker validation`);
|
|
console.log(" (Docker images will need to be built before publishing)");
|
|
}
|
|
|
|
// Check S3 artifacts exist
|
|
console.log(`Checking S3 artifacts for ${shortCommit}...`);
|
|
const endpointUrl = "https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com";
|
|
|
|
// Get credentials
|
|
let awsAccessKeyId = process.env.R2_RELEASES_ACCESS_KEY_ID;
|
|
if (!awsAccessKeyId) {
|
|
const result = await $`op read "op://Engineering/rivet-releases R2 Upload/username"`;
|
|
awsAccessKeyId = result.stdout.trim();
|
|
}
|
|
let awsSecretAccessKey = process.env.R2_RELEASES_SECRET_ACCESS_KEY;
|
|
if (!awsSecretAccessKey) {
|
|
const result = await $`op read "op://Engineering/rivet-releases R2 Upload/password"`;
|
|
awsSecretAccessKey = result.stdout.trim();
|
|
}
|
|
|
|
const awsEnv = {
|
|
AWS_ACCESS_KEY_ID: awsAccessKeyId,
|
|
AWS_SECRET_ACCESS_KEY: awsSecretAccessKey,
|
|
AWS_DEFAULT_REGION: "auto",
|
|
};
|
|
|
|
const commitPrefix = `sandbox-agent/${shortCommit}/`;
|
|
const listResult = await $({
|
|
env: awsEnv,
|
|
shell: true,
|
|
stdio: ["pipe", "pipe", "inherit"],
|
|
})`aws s3api list-objects --bucket rivet-releases --prefix ${commitPrefix} --endpoint-url ${endpointUrl}`;
|
|
const files = JSON.parse(listResult.stdout);
|
|
|
|
if (!Array.isArray(files?.Contents) || files.Contents.length === 0) {
|
|
throw new Error(`No S3 artifacts found for version ${version} (commit ${shortCommit}) under ${commitPrefix}`);
|
|
}
|
|
|
|
console.log(`✅ S3 artifacts exist (${files.Contents.length} files found)`);
|
|
}
|
|
|
|
async function runLocalChecks(opts: ReleaseOpts) {
|
|
console.log("Running local checks...");
|
|
|
|
// Cargo check
|
|
console.log("Running cargo check...");
|
|
try {
|
|
await $({ stdio: "inherit", cwd: opts.root })`cargo check`;
|
|
console.log("✅ Cargo check passed");
|
|
} catch (err) {
|
|
console.error("❌ Cargo check failed");
|
|
throw err;
|
|
}
|
|
|
|
// Cargo fmt check
|
|
console.log("Running cargo fmt --check...");
|
|
try {
|
|
await $({ stdio: "inherit", cwd: opts.root })`cargo fmt --check`;
|
|
console.log("✅ Cargo fmt check passed");
|
|
} catch (err) {
|
|
console.error("❌ Cargo fmt check failed");
|
|
throw err;
|
|
}
|
|
|
|
// TypeScript type check
|
|
console.log("Running TypeScript type check...");
|
|
try {
|
|
await $({ stdio: "inherit", cwd: opts.root })`pnpm typecheck`;
|
|
console.log("✅ TypeScript type check passed");
|
|
} catch (err) {
|
|
console.error("❌ TypeScript type check failed");
|
|
throw err;
|
|
}
|
|
|
|
console.log("✅ All local checks passed");
|
|
}
|
|
|
|
async function runCiChecks(opts: ReleaseOpts) {
|
|
console.log("Running CI checks...");
|
|
|
|
// TypeScript type check
|
|
console.log("Running TypeScript type check...");
|
|
try {
|
|
await $({ stdio: "inherit", cwd: opts.root })`pnpm typecheck`;
|
|
console.log("✅ TypeScript type check passed");
|
|
} catch (err) {
|
|
console.error("❌ TypeScript type check failed");
|
|
throw err;
|
|
}
|
|
|
|
console.log("✅ All CI checks passed");
|
|
}
|
|
|
|
async function getVersionFromArgs(opts: { version?: string; major?: boolean; minor?: boolean; patch?: boolean }): Promise<string> {
|
|
// Check if explicit version is provided via --version flag
|
|
if (opts.version) {
|
|
return opts.version;
|
|
}
|
|
|
|
// Check for version bump flags
|
|
if (!opts.major && !opts.minor && !opts.patch) {
|
|
throw new Error("Must provide either --version, --major, --minor, or --patch");
|
|
}
|
|
|
|
// Get latest version from git tags and calculate new one
|
|
const latestVersion = await getLatestGitVersion();
|
|
if (!latestVersion) {
|
|
throw new Error("No existing version tags found. Use --version to set an explicit version.");
|
|
}
|
|
console.log(`Latest git version: ${latestVersion}`);
|
|
|
|
let newVersion: string | null = null;
|
|
|
|
if (opts.major) {
|
|
newVersion = semver.inc(latestVersion, "major");
|
|
} else if (opts.minor) {
|
|
newVersion = semver.inc(latestVersion, "minor");
|
|
} else if (opts.patch) {
|
|
newVersion = semver.inc(latestVersion, "patch");
|
|
}
|
|
|
|
if (!newVersion) {
|
|
throw new Error("Failed to calculate new version");
|
|
}
|
|
|
|
return newVersion;
|
|
}
|
|
|
|
// Available steps
|
|
const STEPS = [
|
|
"confirm-release",
|
|
"update-version",
|
|
"run-local-checks",
|
|
"git-commit",
|
|
"git-push",
|
|
"trigger-workflow",
|
|
"validate-reuse-version",
|
|
"run-ci-checks",
|
|
"build-js-artifacts",
|
|
"publish-crates",
|
|
"publish-npm-libraries",
|
|
"publish-npm-cli",
|
|
"tag-docker",
|
|
"promote-artifacts",
|
|
"push-tag",
|
|
"create-github-release",
|
|
] as const;
|
|
|
|
const PHASES = ["setup-local", "setup-ci", "complete-ci"] as const;
|
|
|
|
type Step = (typeof STEPS)[number];
|
|
type Phase = (typeof PHASES)[number];
|
|
|
|
// Map phases to individual steps
|
|
const PHASE_MAP: Record<Phase, Step[]> = {
|
|
// These steps modify the source code, so they need to be ran & committed
|
|
// locally. CI cannot push commits.
|
|
//
|
|
// run-local-checks runs cargo check, cargo fmt, and type checks to fail
|
|
// fast before committing/pushing.
|
|
"setup-local": ["confirm-release", "update-version", "run-local-checks", "git-commit", "git-push", "trigger-workflow"],
|
|
// These steps validate the repository and build JS artifacts before
|
|
// triggering release.
|
|
"setup-ci": ["validate-reuse-version", "run-ci-checks", "build-js-artifacts"],
|
|
// These steps run after the required artifacts have been successfully built.
|
|
// Note: update-version is included here to ensure Cargo.toml versions are
|
|
// updated before publishing when using --reuse-engine-version.
|
|
"complete-ci": [
|
|
"update-version",
|
|
"publish-crates",
|
|
"publish-npm-libraries",
|
|
"publish-npm-cli",
|
|
"tag-docker",
|
|
"promote-artifacts",
|
|
"push-tag",
|
|
"create-github-release",
|
|
],
|
|
};
|
|
|
|
async function main() {
|
|
// Setup commander
|
|
program
|
|
.name("release")
|
|
.description("Release a new version of sandbox-agent")
|
|
.option("--major", "Bump major version")
|
|
.option("--minor", "Bump minor version")
|
|
.option("--patch", "Bump patch version")
|
|
.option("--version <version>", "Set specific version")
|
|
.option("--override-commit <commit>", "Override the commit to pull artifacts from (defaults to current commit)")
|
|
.option(
|
|
"--reuse-engine-version <version-or-commit>",
|
|
"Reuse artifacts and Docker images from a previous version (e.g., 0.1.0) or git revision (e.g., bb7f292)",
|
|
)
|
|
.option("--latest", "Tag version as the latest version", true)
|
|
.option("--no-latest", "Do not tag version as the latest version")
|
|
.option("--no-validate-git", "Skip git validation (for testing)")
|
|
.option("--only-steps <steps>", `Run specific steps (comma-separated). Available: ${STEPS.join(", ")}`)
|
|
.option("--phase <phase>", `Run a release phase (comma-separated). Available: ${PHASES.join(", ")}`)
|
|
.parse();
|
|
|
|
const opts = program.opts();
|
|
|
|
// Parse requested steps
|
|
if (!opts.phase && !opts.onlySteps) {
|
|
throw new Error("Must provide either --phase or --only-steps. Run with --help for more information.");
|
|
}
|
|
|
|
if (opts.phase && opts.onlySteps) {
|
|
throw new Error("Cannot use both --phase and --only-steps together");
|
|
}
|
|
|
|
const requestedSteps = new Set<Step>();
|
|
if (opts.onlySteps) {
|
|
const steps = opts.onlySteps.split(",").map((s: string) => s.trim());
|
|
for (const step of steps) {
|
|
if (!STEPS.includes(step as Step)) {
|
|
throw new Error(`Invalid step: ${step}. Available steps: ${STEPS.join(", ")}`);
|
|
}
|
|
requestedSteps.add(step as Step);
|
|
}
|
|
} else if (opts.phase) {
|
|
const phases = opts.phase.split(",").map((s: string) => s.trim());
|
|
for (const phase of phases) {
|
|
if (!PHASES.includes(phase as Phase)) {
|
|
throw new Error(`Invalid phase: ${phase}. Available phases: ${PHASES.join(", ")}`);
|
|
}
|
|
const steps = PHASE_MAP[phase as Phase];
|
|
for (const step of steps) {
|
|
requestedSteps.add(step);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to check if a step should run
|
|
const shouldRunStep = (step: Step): boolean => {
|
|
return requestedSteps.has(step);
|
|
};
|
|
|
|
// Get version from arguments or calculate based on flags
|
|
const version = await getVersionFromArgs({
|
|
version: opts.version,
|
|
major: opts.major,
|
|
minor: opts.minor,
|
|
patch: opts.patch,
|
|
});
|
|
|
|
assert(
|
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/.test(
|
|
version,
|
|
),
|
|
"version must be a valid semantic version",
|
|
);
|
|
|
|
// Automatically determine if this should be tagged as latest
|
|
// Can be overridden by --latest or --no-latest flags
|
|
let isLatest: boolean;
|
|
if (opts.latest !== undefined) {
|
|
// User explicitly set the flag
|
|
isLatest = opts.latest;
|
|
} else {
|
|
// Auto-determine based on version
|
|
isLatest = await shouldTagAsLatest(version);
|
|
console.log(`Auto-determined latest flag: ${isLatest} (version: ${version})`);
|
|
}
|
|
|
|
const parsedVersion = semver.parse(version);
|
|
assert(parsedVersion !== null, "version must be parseable");
|
|
const minorVersionChannel = `${parsedVersion.major}.${parsedVersion.minor}.x`;
|
|
|
|
// Setup opts
|
|
let commit: string;
|
|
if (opts.overrideCommit) {
|
|
// Manually override commit
|
|
commit = opts.overrideCommit;
|
|
} else {
|
|
// Read commit
|
|
const result = await $`git rev-parse HEAD`;
|
|
commit = result.stdout.trim();
|
|
}
|
|
|
|
const releaseOpts: ReleaseOpts = {
|
|
root: ROOT_DIR,
|
|
version: version,
|
|
latest: isLatest,
|
|
minorVersionChannel,
|
|
commit,
|
|
reuseEngineVersion: opts.reuseEngineVersion,
|
|
};
|
|
|
|
if (releaseOpts.commit.length == 40) {
|
|
releaseOpts.commit = releaseOpts.commit.slice(0, 7);
|
|
}
|
|
|
|
assertEquals(releaseOpts.commit.length, 7, "must use 7 char short commit");
|
|
|
|
if (opts.validateGit && !shouldRunStep("run-ci-checks")) {
|
|
// HACK: Skip setup-ci because for some reason there's changes in the setup step but only in GitHub Actions
|
|
await validateGit(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("confirm-release")) {
|
|
console.log("==> Release Confirmation");
|
|
console.log(`\nRelease Details:`);
|
|
console.log(` Version: ${releaseOpts.version}`);
|
|
console.log(` Latest: ${releaseOpts.latest}`);
|
|
console.log(` Minor channel: ${releaseOpts.minorVersionChannel}`);
|
|
console.log(` Commit: ${releaseOpts.commit}`);
|
|
if (releaseOpts.reuseEngineVersion) {
|
|
console.log(` Reusing engine version: ${releaseOpts.reuseEngineVersion}`);
|
|
}
|
|
|
|
// Get current branch
|
|
const branchResult = await $`git rev-parse --abbrev-ref HEAD`;
|
|
const branch = branchResult.stdout.trim();
|
|
console.log(` Branch: ${branch}`);
|
|
|
|
// Get and display recent versions
|
|
const allVersions = await getAllGitVersions();
|
|
|
|
if (allVersions.length > 0) {
|
|
// Find the latest stable version (excluding prereleases)
|
|
const stableVersions = allVersions.filter((v) => {
|
|
const parsed = semver.parse(v);
|
|
return parsed && parsed.prerelease.length === 0;
|
|
});
|
|
const latestStableVersion = stableVersions[0] || null;
|
|
|
|
console.log(`\nRecent versions:`);
|
|
const recentVersions = allVersions.slice(0, 10);
|
|
for (const version of recentVersions) {
|
|
const isLatest = version === latestStableVersion;
|
|
const marker = isLatest ? " (latest)" : "";
|
|
console.log(` - ${version}${marker}`);
|
|
}
|
|
}
|
|
|
|
// Prompt for confirmation
|
|
const readline = await import("node:readline");
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
const answer = await new Promise<string>((resolve) => {
|
|
rl.question("\nProceed with release? (yes/no): ", resolve);
|
|
});
|
|
rl.close();
|
|
|
|
if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
|
|
console.log("Release cancelled");
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log("✅ Release confirmed");
|
|
}
|
|
|
|
if (shouldRunStep("update-version")) {
|
|
console.log("==> Updating Version");
|
|
await updateVersion(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("run-local-checks")) {
|
|
console.log("==> Running Local Checks");
|
|
await runLocalChecks(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("git-commit")) {
|
|
assert(opts.validateGit, "cannot commit without git validation");
|
|
console.log("==> Committing Changes");
|
|
await $({ stdio: "inherit" })`git add .`;
|
|
await $({
|
|
stdio: "inherit",
|
|
shell: true,
|
|
})`git commit --allow-empty -m "chore(release): update version to ${releaseOpts.version}"`;
|
|
}
|
|
|
|
if (shouldRunStep("git-push")) {
|
|
assert(opts.validateGit, "cannot push without git validation");
|
|
console.log("==> Pushing Commits");
|
|
const branchResult = await $`git rev-parse --abbrev-ref HEAD`;
|
|
const branch = branchResult.stdout.trim();
|
|
if (branch === "main") {
|
|
// Push on main
|
|
await $({ stdio: "inherit" })`git push`;
|
|
} else {
|
|
// Modify current branch
|
|
await $({ stdio: "inherit" })`gt submit --force --no-edit --publish`;
|
|
}
|
|
}
|
|
|
|
if (shouldRunStep("trigger-workflow")) {
|
|
console.log("==> Triggering Workflow");
|
|
const branchResult = await $`git rev-parse --abbrev-ref HEAD`;
|
|
const branch = branchResult.stdout.trim();
|
|
const latestFlag = releaseOpts.latest ? "true" : "false";
|
|
|
|
// Build workflow command
|
|
let workflowCmd = `gh workflow run .github/workflows/release.yaml -f version=${releaseOpts.version} -f latest=${latestFlag}`;
|
|
if (releaseOpts.reuseEngineVersion) {
|
|
workflowCmd += ` -f reuse_engine_version=${releaseOpts.reuseEngineVersion}`;
|
|
}
|
|
workflowCmd += ` --ref ${branch}`;
|
|
|
|
await $({ stdio: "inherit", shell: true })`${workflowCmd}`;
|
|
|
|
// Get repository info and print workflow link
|
|
const repoResult = await $`gh repo view --json nameWithOwner -q .nameWithOwner`;
|
|
const repo = repoResult.stdout.trim();
|
|
console.log(`\nWorkflow triggered: https://github.com/${repo}/actions/workflows/release.yaml`);
|
|
console.log(`View all runs: https://github.com/${repo}/actions`);
|
|
}
|
|
|
|
if (shouldRunStep("validate-reuse-version")) {
|
|
if (releaseOpts.reuseEngineVersion) {
|
|
console.log("==> Validating Reuse Version");
|
|
await validateReuseVersion(releaseOpts.reuseEngineVersion);
|
|
}
|
|
}
|
|
|
|
if (shouldRunStep("run-ci-checks")) {
|
|
console.log("==> Running CI Checks");
|
|
await runCiChecks(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("build-js-artifacts")) {
|
|
console.log("==> Building JS Artifacts");
|
|
await buildJsArtifacts(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("publish-crates")) {
|
|
console.log("==> Publishing Crates");
|
|
await publishCrates(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("publish-npm-libraries")) {
|
|
console.log("==> Publishing NPM Libraries");
|
|
await publishNpmLibraries(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("publish-npm-cli")) {
|
|
console.log("==> Publishing NPM CLI");
|
|
await publishNpmCli(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("tag-docker")) {
|
|
console.log("==> Tagging Docker");
|
|
await tagDocker(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("promote-artifacts")) {
|
|
console.log("==> Promoting Artifacts");
|
|
await promoteArtifacts(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("push-tag")) {
|
|
console.log("==> Pushing Tag");
|
|
await createAndPushTag(releaseOpts);
|
|
}
|
|
|
|
if (shouldRunStep("create-github-release")) {
|
|
console.log("==> Creating GitHub Release");
|
|
await createGitHubRelease(releaseOpts);
|
|
}
|
|
|
|
console.log("==> Complete");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|