Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
9.5 kB · 280 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281#!/usr/bin/env node// Publish Electron release to DigitalOcean Spaces + register with silo.//// Usage:// node scripts/publish-release.mjs [--notes "Release notes"] [--dry-run]//// Requires env vars (from silo/.env or environment):// SPACES_KEY, SPACES_SECRET, SPACES_ENDPOINT// SILO_URL (default: https://silo.aesthetic.computer)// PUBLISH_SECRET (shared secret for silo admin API)
import fs from "fs";import path from "path";import { fileURLToPath } from "url";import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";import { Upload } from "@aws-sdk/lib-storage";
const __dirname = path.dirname(fileURLToPath(import.meta.url));const distDir = path.join(__dirname, "..", "dist");const pkgPath = path.join(__dirname, "..", "package.json");
// Load silo/.env if availableconst siloEnvPath = path.join(__dirname, "..", "..", "silo", ".env");if (fs.existsSync(siloEnvPath)) { for (const line of fs.readFileSync(siloEnvPath, "utf8").split("\n")) { const match = line.match(/^([A-Z_]+)=(.*)$/); if (match && !process.env[match[1]]) { process.env[match[1]] = match[2].replace(/^["']|["']$/g, ""); } }}
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));const version = pkg.version;
const BUCKET = "releases-aesthetic-computer";const PREFIX = "desktop/";const BASE_URL = "https://releases.aesthetic.computer/desktop";const SILO_URL = process.env.SILO_URL || "https://silo.aesthetic.computer";const PUBLISH_SECRET = process.env.PUBLISH_SECRET || "";
const args = process.argv.slice(2);const dryRun = args.includes("--dry-run");const notesIdx = args.indexOf("--notes");const releaseNotes = notesIdx >= 0 ? args[notesIdx + 1] || "" : "";
const s3 = new S3Client({ endpoint: process.env.SPACES_ENDPOINT || "https://sfo3.digitaloceanspaces.com", region: "us-east-1", credentials: { accessKeyId: process.env.SPACES_KEY || "", secretAccessKey: process.env.SPACES_SECRET || "", }, forcePathStyle: false, // DO Spaces' S3 impl still expects the legacy XML Checksum behavior; // AWS SDK v3 otherwise sends checksum headers that Spaces rejects with // InvalidArgument 400 during multipart. requestChecksumCalculation: "WHEN_REQUIRED", responseChecksumValidation: "WHEN_REQUIRED",});
// lib-storage's Upload force-defaults ChecksumAlgorithm to CRC32 on// CreateMultipartUpload (`this.params.ChecksumAlgorithm || CRC32`), which DO// Spaces rejects with InvalidArgument 400. Strip the checksum headers before// the request is signed.s3.middlewareStack.add( (next) => async (args) => { if (args.request?.headers) { delete args.request.headers["x-amz-checksum-algorithm"]; delete args.request.headers["x-amz-sdk-checksum-algorithm"]; } return next(args); }, { step: "build", name: "stripChecksumHeadersForSpaces" },);
// Files to upload: manifests + binaries matching current versionconst manifestFiles = ["latest-linux.yml", "latest-mac.yml", "latest.yml"];const binaryPatterns = [".AppImage", ".dmg", ".zip", ".exe", ".deb", ".rpm", ".blockmap"];
function collectFiles() { if (!fs.existsSync(distDir)) { console.error("No dist/ directory found. Run a build first."); process.exit(1); }
const files = []; for (const name of fs.readdirSync(distDir)) { const fullPath = path.join(distDir, name); if (!fs.statSync(fullPath).isFile()) continue;
// Include manifests if (manifestFiles.includes(name)) { files.push({ name, path: fullPath, isManifest: true }); continue; }
// Include binaries for this version if (binaryPatterns.some((ext) => name.endsWith(ext)) && name.includes(version)) { files.push({ name, path: fullPath, isManifest: false }); } }
return files;}
function parseYml(content) { // Simple YAML parser for electron-builder manifest format const result = {}; for (const line of content.split("\n")) { const match = line.match(/^(\w+):\s*(.+)$/); if (match) { let val = match[2].trim().replace(/^['"]|['"]$/g, ""); if (!isNaN(val) && val !== "") val = Number(val); result[match[1]] = val; } } return result;}
function platformFromManifest(name) { if (name === "latest-linux.yml") return "linux"; if (name === "latest-mac.yml") return "mac"; if (name === "latest.yml") return "win"; return null;}
async function uploadFile(file) { const key = PREFIX + file.name; const size = fs.statSync(file.path).size; const contentType = file.isManifest ? "text/yaml" : "application/octet-stream"; const cacheControl = file.isManifest ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000";
console.log(` Uploading ${file.name} (${(size / 1024 / 1024).toFixed(1)} MB) → s3://${BUCKET}/${key}`);
if (dryRun) return;
// Use multipart for anything over 8 MB — DO Spaces frequently times out // single-part PutObject on large files (e.g. the 200 MB DMG). DO Spaces // rejects the ACL header on CreateMultipartUpload — we set ACL after. if (size > 8 * 1024 * 1024) { const upload = new Upload({ client: s3, params: { Bucket: BUCKET, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType, CacheControl: cacheControl, }, queueSize: 4, partSize: 16 * 1024 * 1024, leavePartsOnError: false, }); upload.on("httpUploadProgress", (p) => { if (p.total) { const pct = ((p.loaded / p.total) * 100).toFixed(0); process.stdout.write(`\r ${pct}% (${(p.loaded / 1024 / 1024).toFixed(1)}/${(p.total / 1024 / 1024).toFixed(1)} MB)`); } }); await upload.done(); process.stdout.write("\n"); // Apply public-read ACL as a follow-up (DO Spaces requires this path // when the initial multipart request can't carry ACL). const { PutObjectAclCommand } = await import("@aws-sdk/client-s3"); await s3.send(new PutObjectAclCommand({ Bucket: BUCKET, Key: key, ACL: "public-read", })); return; }
await s3.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: fs.readFileSync(file.path), ContentType: contentType, CacheControl: cacheControl, ACL: "public-read", }), );}
async function registerWithSilo(platforms) { const url = `${SILO_URL}/api/desktop/register`; const body = { version, platforms, releaseNotes };
console.log(`\n Registering v${version} with silo...`); if (dryRun) { console.log(" [dry-run] Would POST:", JSON.stringify(body, null, 2)); return; }
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-Publish-Secret": PUBLISH_SECRET, }, body: JSON.stringify(body), });
if (!resp.ok) { const err = await resp.text(); throw new Error(`Silo register failed (${resp.status}): ${err}`); }
const data = await resp.json(); console.log(` Registered: ${JSON.stringify(data)}`);}
async function main() { console.log(`\nPublishing Aesthetic Computer Desktop v${version}`); console.log(` Bucket: ${BUCKET}`); console.log(` Silo: ${SILO_URL}`); if (dryRun) console.log(" [DRY RUN - no actual uploads]");
const files = collectFiles(); if (files.length === 0) { console.error("\nNo files to upload. Build first with: npm run build:linux"); process.exit(1); }
console.log(`\nFound ${files.length} files to upload:`); for (const f of files) console.log(` ${f.name} (${f.isManifest ? "manifest" : "binary"})`);
// Upload all files console.log("\nUploading to DigitalOcean Spaces..."); for (const file of files) { await uploadFile(file); }
// Parse manifests to build platform metadata const platforms = {}; for (const file of files.filter((f) => f.isManifest)) { const platform = platformFromManifest(file.name); if (!platform) continue; const content = fs.readFileSync(file.path, "utf8"); const parsed = parseYml(content); if (parsed.path) { // Get size from actual file on disk (yml may not have top-level size) const binaryPath = path.join(distDir, parsed.path); const size = parsed.size || (fs.existsSync(binaryPath) ? fs.statSync(binaryPath).size : 0); platforms[platform] = { filename: parsed.path, size, sha512: parsed.sha512 || "", url: `${BASE_URL}/${parsed.path}`, }; } }
if (Object.keys(platforms).length === 0) { console.warn("\nNo manifest files found - skipping silo registration."); console.log("Upload the latest-*.yml files to register."); return; }
// Register with silo await registerWithSilo(platforms);
console.log(`\nDone! v${version} published.`); console.log(` Download: ${BASE_URL}/`); console.log(` Silo API: ${SILO_URL}/desktop/latest`);}
main().catch((err) => { console.error("\nPublish failed:", err.name, "-", err.message); if (err.$metadata) console.error(" http:", err.$metadata.httpStatusCode); if (err.Code) console.error(" code:", err.Code); if (err.cause) console.error(" cause:", err.cause?.message || err.cause); if (err.stack) console.error(err.stack); process.exit(1);});