mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-13 12:13:09 -06:00
Update source files
This commit is contained in:
605
scripts/release-manager/cli.ts
Normal file
605
scripts/release-manager/cli.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user