Files
smart-rgb/scripts/release-manager/cli.ts
2025-10-31 01:10:53 -05:00

606 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash } from "crypto";
import { mkdir, readFile, stat, unlink, writeFile } from "fs/promises";
import { basename, resolve } from "path";
import { program } from "commander";
import { glob } from "glob";
import { Octokit } from "@octokit/rest";
import { Upload } from "@aws-sdk/lib-storage";
import ora from "ora";
import pc from "picocolors";
import "dotenv/config";
import {
createR2Client,
getReleasesDir,
loadManifest,
createManifest,
saveManifest,
padWithColor,
constrainString,
formatRelativeTime,
} from "./utils.js";
import type {
ArtifactMapping,
Checksums,
FileType,
Manifest,
Platform,
ReleaseFile,
Version,
} from "./types.js";
const ARTIFACT_MAPPINGS: ArtifactMapping[] = [
{
artifactName: "iron-borders-linux-x86_64",
platform: "linux-x86_64",
expectedFiles: [
{ glob: "**/*.AppImage", type: "appimage" },
{ glob: "**/*.deb", type: "deb" },
{ glob: "**/*.rpm", type: "rpm" },
],
},
{
artifactName: "iron-borders-macos-x86_64",
platform: "macos-x86_64",
expectedFiles: [
{ glob: "**/*.dmg", type: "dmg" },
{ glob: "**/*.app", type: "app" },
],
},
{
artifactName: "iron-borders-macos-aarch64",
platform: "macos-aarch64",
expectedFiles: [
{ glob: "**/*.dmg", type: "dmg" },
{ glob: "**/*.app", type: "app" },
],
},
{
artifactName: "iron-borders-windows-x86_64",
platform: "windows-x86_64",
expectedFiles: [{ glob: "**/*.exe", type: "exe" }],
},
{
artifactName: "iron-borders-windows-aarch64",
platform: "windows-aarch64",
expectedFiles: [{ glob: "**/*.exe", type: "exe" }],
},
];
const artifactNames = ARTIFACT_MAPPINGS.map((m) => m.artifactName);
function validateEnvironment(): void {
const required = [
'GITHUB_TOKEN',
'GITHUB_REPOSITORY',
'R2_BUCKET_NAME',
'R2_BASE_URL',
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(pc.red('\n❌ Missing required environment variables:'));
for (const key of missing) {
console.error(pc.red(` - ${key}`));
}
console.error(
pc.yellow('\nCheck .env file in scripts/release-manager/.env\n')
);
process.exit(1);
}
}
validateEnvironment();
async function downloadArtifact(
octokit: Octokit,
owner: string,
repo: string,
artifactId: number,
outputPath: string
): Promise<void> {
const response = await octokit.rest.actions.downloadArtifact({
owner,
repo,
artifact_id: artifactId,
archive_format: "zip",
});
const arrayBuffer = response.data as unknown as ArrayBuffer;
const buffer = Buffer.from(arrayBuffer);
await mkdir(resolve(outputPath, ".."), { recursive: true });
await writeFile(outputPath, buffer);
}
async function extractZip(zipPath: string, outputDir: string): Promise<void> {
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(zipPath);
zip.extractAllTo(outputDir, true);
}
async function listRecentRuns(
octokit: Octokit,
owner: string,
repo: string
): Promise<void> {
const workflows = await octokit.rest.actions.listRepoWorkflows({
owner,
repo,
});
const buildsWorkflow = workflows.data.workflows.find(
(w) => w.path === ".github/workflows/builds.yml"
);
if (!buildsWorkflow) {
throw new Error("builds.yml workflow not found");
}
const runs = await octokit.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: buildsWorkflow.id,
per_page: 10,
});
console.log(pc.bold(pc.cyan("\n📋 Recent builds workflow runs:\n")));
console.log(
pc.dim(
`${"Run ID".padEnd(12)} ${"Status".padEnd(12)} ${"Branch".padEnd(
20
)} ${"Created"}`
)
);
console.log(pc.dim("─".repeat(80)));
for (const run of runs.data.workflow_runs) {
let status: string;
if (run.status === "completed") {
status =
run.conclusion === "success"
? pc.green("✓ success")
: pc.red(`${run.conclusion}`);
} else {
status = pc.yellow(`${run.status}`);
}
const date = formatRelativeTime(new Date(run.created_at));
const branch = constrainString(run.head_branch || "unknown", 20);
console.log(
`${String(run.id).padEnd(12)} ${padWithColor(status, 12)} ${branch.padEnd(
20
)} ${date}`
);
}
console.log("");
}
async function computeChecksums(filePath: string): Promise<Checksums> {
const buffer = await readFile(filePath);
return {
sha256: createHash("sha256").update(buffer).digest("hex"),
sha512: createHash("sha512").update(buffer).digest("hex"),
md5: createHash("md5").update(buffer).digest("hex"),
};
}
async function findFiles(
baseDir: string,
artifactName: string,
expectedFiles: { glob: string; type: FileType }[]
): Promise<{ path: string; type: FileType }[]> {
const artifactDir = resolve(baseDir, artifactName);
const results: { path: string; type: FileType }[] = [];
for (const { glob: pattern, type } of expectedFiles) {
const matches = await glob(pattern, {
cwd: artifactDir,
absolute: true,
nodir: true,
});
for (const match of matches) {
results.push({ path: match, type });
}
}
return results;
}
async function uploadToR2(
localPath: string,
remotePath: string,
bucket: string,
s3Client: ReturnType<typeof createR2Client>,
onProgress?: (percent: number) => void
): Promise<void> {
try {
const fileContent = await readFile(localPath);
const upload = new Upload({
client: s3Client,
params: {
Bucket: bucket,
Key: remotePath,
Body: fileContent,
},
});
if (onProgress) {
upload.on('httpUploadProgress', (progress) => {
if (progress.loaded && progress.total) {
const percent = Math.round((progress.loaded / progress.total) * 100);
onProgress(percent);
}
});
}
await upload.done();
} catch (error: any) {
throw new Error(
`Failed to upload to R2 bucket "${bucket}": ${error.message}\n` +
`Path: ${remotePath}\n` +
`Hint: Check that the bucket exists and your R2 credentials are correct`
);
}
}
async function processVersion(
version: string,
bucket: string,
baseUrl: string,
skipUpload: boolean,
s3Client: ReturnType<typeof createR2Client>
): Promise<Version> {
const releasesDir = getReleasesDir();
const versionDir = resolve(releasesDir, version);
const version_obj: Version = {
version,
released: new Date().toISOString(),
visible: true,
platforms: {},
};
for (const mapping of ARTIFACT_MAPPINGS) {
const spinner = ora(`Finding files for ${mapping.platform}`).start();
try {
const files = await findFiles(
versionDir,
mapping.artifactName,
mapping.expectedFiles
);
if (files.length === 0) {
spinner.warn(pc.yellow(`No files found for ${mapping.platform}`));
continue;
}
const releaseFiles: ReleaseFile[] = [];
for (const { path: filePath, type } of files) {
const filename = basename(filePath);
const remotePath = `releases/v${version}/${mapping.platform}/${filename}`;
spinner.text = `Computing checksums for ${filename}`;
const checksums = await computeChecksums(filePath);
const fileStats = await stat(filePath);
if (!skipUpload) {
spinner.text = `Uploading ${filename} (0%)`;
await uploadToR2(filePath, remotePath, bucket, s3Client, (percent) => {
spinner.text = `Uploading ${filename} (${percent}%)`;
});
}
releaseFiles.push({
type,
url: `${baseUrl}/${remotePath}`,
filename,
size: fileStats.size,
checksums,
});
}
version_obj.platforms[mapping.platform] = { files: releaseFiles };
spinner.succeed(
pc.green(`Processed ${mapping.platform} (${releaseFiles.length} files)`)
);
} catch (error) {
spinner.fail(pc.red(`Failed to process ${mapping.platform}`));
throw error;
}
}
return version_obj;
}
program
.name("release-manager")
.description("Manage Iron Borders releases")
.version("1.0.0");
program
.command("download [version] [run-id]")
.description("Download and extract GitHub Actions artifacts")
.action(async (version, runId) => {
try {
const [owner, repo] = process.env.GITHUB_REPOSITORY!.split('/');
const token = process.env.GITHUB_TOKEN!;
const octokit = new Octokit({ auth: token });
if (!version && !runId) {
await listRecentRuns(octokit, owner, repo);
throw new Error(
"No version and run ID specified. Provide both: download <version> <run-id>"
);
}
if (!version || !runId) {
throw new Error(
"Both version and run ID are required: download <version> <run-id>"
);
}
const runIdNum = parseInt(runId, 10);
const run = await octokit.rest.actions.getWorkflowRun({
owner,
repo,
run_id: runIdNum,
});
if (run.data.status !== "completed") {
throw new Error(
`Cannot download artifacts from run ${runId}: workflow is still ${run.data.status}`
);
}
console.log(
pc.bold(pc.cyan(`\n📦 Downloading artifacts from run ${runId}\n`))
);
const artifacts = await octokit.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: runIdNum,
});
const releasesDir = getReleasesDir();
const versionDir = resolve(releasesDir, version);
await mkdir(versionDir, { recursive: true });
for (const artifactName of artifactNames) {
const artifact = artifacts.data.artifacts.find(
(a) => a.name === artifactName
);
if (!artifact) {
console.log(
pc.yellow(`⚠️ Artifact ${artifactName} not found, skipping`)
);
continue;
}
const spinner = ora(`Downloading ${artifactName}`).start();
try {
const zipPath = resolve(versionDir, `${artifactName}.zip`);
await downloadArtifact(octokit, owner, repo, artifact.id, zipPath);
spinner.text = `Extracting ${artifactName}`;
const extractDir = resolve(versionDir, artifactName);
await extractZip(zipPath, extractDir);
await unlink(zipPath);
spinner.succeed(pc.green(`Downloaded and extracted ${artifactName}`));
} catch (error) {
spinner.fail(pc.red(`Failed to download ${artifactName}`));
throw error;
}
}
console.log(
pc.bold(pc.green(`\n✅ All artifacts downloaded to ${versionDir}\n`))
);
} catch (error) {
console.error(
pc.red("\n❌ Error:"),
error instanceof Error ? error.message : error
);
process.exit(1);
}
});
program
.command("upload <version>")
.description("Upload release artifacts to R2 and update manifest")
.option("--latest", "Set this version as latest")
.option("--skip-upload", "Skip upload, only generate manifest locally")
.action(async (version, options) => {
try {
const { latest, skipUpload } = options;
const bucket = process.env.R2_BUCKET_NAME!;
const baseUrl = process.env.R2_BASE_URL!;
console.log(pc.bold(pc.cyan(`\n🚀 Uploading release ${version}\n`)));
const s3Client = createR2Client();
const spinner = ora("Loading existing manifest").start();
let manifest: Manifest;
try {
manifest = await loadManifest(bucket, s3Client);
spinner.succeed("Loaded manifest");
} catch (error: any) {
if (error.message.includes("not found")) {
spinner.warn("Manifest not found, creating new one");
manifest = await createManifest();
} else {
spinner.fail("Failed to load manifest");
throw error;
}
}
const versionObj = await processVersion(
version,
bucket,
baseUrl,
skipUpload,
s3Client
);
const existingIndex = manifest.versions.findIndex(
(v) => v.version === version
);
if (existingIndex >= 0) {
manifest.versions[existingIndex] = versionObj;
console.log(
pc.yellow(`\n⚠️ Updated existing version ${version} in manifest`)
);
} else {
manifest.versions.unshift(versionObj);
console.log(pc.green(`\n✅ Added new version ${version} to manifest`));
}
manifest.versions.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true })
);
const shouldSetLatest =
latest || !manifest.latest || manifest.latest < version;
if (shouldSetLatest) {
manifest.latest = version;
console.log(pc.cyan(`📌 Set ${version} as latest version`));
}
if (!skipUpload) {
const manifestSpinner = ora("Uploading manifest").start();
await saveManifest(manifest, bucket, s3Client);
manifestSpinner.succeed("Uploaded manifest");
} else {
console.log(pc.yellow("\n⚠ Skipped upload (--skip-upload flag)"));
console.log("\nGenerated manifest:");
console.log(JSON.stringify(manifest, null, 2));
}
console.log(
pc.bold(pc.green(`\n✅ Release ${version} uploaded successfully!\n`))
);
} catch (error) {
console.error(pc.red("\n❌ Error:"), error);
process.exit(1);
}
});
program
.command("list")
.description("List all available releases")
.action(async () => {
try {
const bucket = process.env.R2_BUCKET_NAME!;
const s3Client = createR2Client();
const manifest = await loadManifest(bucket, s3Client);
console.log(pc.bold(pc.cyan("\n📦 Available Releases\n")));
console.log(
pc.dim(
`${"Version".padEnd(12)} ${"Visible".padEnd(10)} ${"Released".padEnd(
30
)} ${"Status"}`
)
);
console.log(pc.dim("─".repeat(80)));
for (const version of manifest.versions) {
const isLatest = version.version === manifest.latest;
const versionStr = isLatest
? pc.green(version.version)
: version.version;
const visibleStr = version.visible ? pc.green("✓ Yes") : pc.red("✗ No");
const releasedStr = new Date(version.released).toLocaleString();
const statusStr = isLatest ? pc.cyan(" (latest)") : "";
console.log(
`${padWithColor(versionStr, 12)} ${padWithColor(
visibleStr,
10
)} ${releasedStr.padEnd(30)} ${statusStr}`
);
}
console.log("");
} catch (error: any) {
if (error.message.includes("not found")) {
console.error(
pc.red("\n❌ No releases found (manifest.json does not exist)\n")
);
process.exit(1);
}
console.error(pc.red("\n❌ Error:"), error.message);
process.exit(1);
}
});
program
.command("set-latest")
.description("Set a version as the latest in the manifest")
.requiredOption(
"-v, --version <version>",
"Version to set as latest (e.g., 0.5.7)"
)
.action(async (options) => {
try {
const { version } = options;
const bucket = process.env.R2_BUCKET_NAME!;
const s3Client = createR2Client();
const spinner = ora("Loading manifest").start();
const manifest = await loadManifest(bucket, s3Client);
spinner.succeed("Loaded manifest");
const versionExists = manifest.versions.some(
(v) => v.version === version
);
if (!versionExists) {
console.error(pc.red(`\n❌ Version ${version} not found in manifest`));
console.log(pc.yellow("\nAvailable versions:"));
manifest.versions.forEach((v) => {
const indicator =
v.version === manifest.latest ? pc.green(" (current latest)") : "";
console.log(` - ${v.version}${indicator}`);
});
process.exit(1);
}
const previousLatest = manifest.latest;
manifest.latest = version;
const uploadSpinner = ora("Updating manifest").start();
await saveManifest(manifest, bucket, s3Client);
uploadSpinner.succeed("Updated manifest");
console.log(pc.bold(pc.green(`\n✅ Latest version updated:`)));
console.log(` ${previousLatest}${pc.cyan(version)}\n`);
} catch (error) {
console.error(pc.red("\n❌ Error:"), error);
process.exit(1);
}
});
program.parse();