Monorepo for Aesthetic.Computer aesthetic.computer
at main 219 lines 7.0 kB view raw
1#!/usr/bin/env node 2// Publish Electron release to DigitalOcean Spaces + register with silo. 3// 4// Usage: 5// node scripts/publish-release.mjs [--notes "Release notes"] [--dry-run] 6// 7// Requires env vars (from silo/.env or environment): 8// SPACES_KEY, SPACES_SECRET, SPACES_ENDPOINT 9// SILO_URL (default: https://silo.aesthetic.computer) 10// PUBLISH_SECRET (shared secret for silo admin API) 11 12import fs from "fs"; 13import path from "path"; 14import { fileURLToPath } from "url"; 15import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; 16 17const __dirname = path.dirname(fileURLToPath(import.meta.url)); 18const distDir = path.join(__dirname, "..", "dist"); 19const pkgPath = path.join(__dirname, "..", "package.json"); 20 21// Load silo/.env if available 22const siloEnvPath = path.join(__dirname, "..", "..", "silo", ".env"); 23if (fs.existsSync(siloEnvPath)) { 24 for (const line of fs.readFileSync(siloEnvPath, "utf8").split("\n")) { 25 const match = line.match(/^([A-Z_]+)=(.*)$/); 26 if (match && !process.env[match[1]]) { 27 process.env[match[1]] = match[2].replace(/^["']|["']$/g, ""); 28 } 29 } 30} 31 32const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); 33const version = pkg.version; 34 35const BUCKET = "releases-aesthetic-computer"; 36const PREFIX = "desktop/"; 37const BASE_URL = "https://releases.aesthetic.computer/desktop"; 38const SILO_URL = process.env.SILO_URL || "https://silo.aesthetic.computer"; 39const PUBLISH_SECRET = process.env.PUBLISH_SECRET || ""; 40 41const args = process.argv.slice(2); 42const dryRun = args.includes("--dry-run"); 43const notesIdx = args.indexOf("--notes"); 44const releaseNotes = notesIdx >= 0 ? args[notesIdx + 1] || "" : ""; 45 46const s3 = new S3Client({ 47 endpoint: process.env.SPACES_ENDPOINT || "https://sfo3.digitaloceanspaces.com", 48 region: "us-east-1", 49 credentials: { 50 accessKeyId: process.env.SPACES_KEY || "", 51 secretAccessKey: process.env.SPACES_SECRET || "", 52 }, 53 forcePathStyle: false, 54}); 55 56// Files to upload: manifests + binaries matching current version 57const manifestFiles = ["latest-linux.yml", "latest-mac.yml", "latest.yml"]; 58const binaryPatterns = [".AppImage", ".dmg", ".zip", ".exe", ".deb", ".rpm", ".blockmap"]; 59 60function collectFiles() { 61 if (!fs.existsSync(distDir)) { 62 console.error("No dist/ directory found. Run a build first."); 63 process.exit(1); 64 } 65 66 const files = []; 67 for (const name of fs.readdirSync(distDir)) { 68 const fullPath = path.join(distDir, name); 69 if (!fs.statSync(fullPath).isFile()) continue; 70 71 // Include manifests 72 if (manifestFiles.includes(name)) { 73 files.push({ name, path: fullPath, isManifest: true }); 74 continue; 75 } 76 77 // Include binaries for this version 78 if (binaryPatterns.some((ext) => name.endsWith(ext)) && name.includes(version)) { 79 files.push({ name, path: fullPath, isManifest: false }); 80 } 81 } 82 83 return files; 84} 85 86function parseYml(content) { 87 // Simple YAML parser for electron-builder manifest format 88 const result = {}; 89 for (const line of content.split("\n")) { 90 const match = line.match(/^(\w+):\s*(.+)$/); 91 if (match) { 92 let val = match[2].trim().replace(/^['"]|['"]$/g, ""); 93 if (!isNaN(val) && val !== "") val = Number(val); 94 result[match[1]] = val; 95 } 96 } 97 return result; 98} 99 100function platformFromManifest(name) { 101 if (name === "latest-linux.yml") return "linux"; 102 if (name === "latest-mac.yml") return "mac"; 103 if (name === "latest.yml") return "win"; 104 return null; 105} 106 107async function uploadFile(file) { 108 const key = PREFIX + file.name; 109 const body = fs.readFileSync(file.path); 110 const contentType = file.isManifest 111 ? "text/yaml" 112 : "application/octet-stream"; 113 const cacheControl = file.isManifest 114 ? "no-cache, no-store, must-revalidate" 115 : "public, max-age=31536000"; 116 117 console.log(` Uploading ${file.name} (${(body.length / 1024 / 1024).toFixed(1)} MB) → s3://${BUCKET}/${key}`); 118 119 if (dryRun) return; 120 121 await s3.send( 122 new PutObjectCommand({ 123 Bucket: BUCKET, 124 Key: key, 125 Body: body, 126 ContentType: contentType, 127 CacheControl: cacheControl, 128 ACL: "public-read", 129 }), 130 ); 131} 132 133async function registerWithSilo(platforms) { 134 const url = `${SILO_URL}/api/desktop/register`; 135 const body = { version, platforms, releaseNotes }; 136 137 console.log(`\n Registering v${version} with silo...`); 138 if (dryRun) { 139 console.log(" [dry-run] Would POST:", JSON.stringify(body, null, 2)); 140 return; 141 } 142 143 const resp = await fetch(url, { 144 method: "POST", 145 headers: { 146 "Content-Type": "application/json", 147 "X-Publish-Secret": PUBLISH_SECRET, 148 }, 149 body: JSON.stringify(body), 150 }); 151 152 if (!resp.ok) { 153 const err = await resp.text(); 154 throw new Error(`Silo register failed (${resp.status}): ${err}`); 155 } 156 157 const data = await resp.json(); 158 console.log(` Registered: ${JSON.stringify(data)}`); 159} 160 161async function main() { 162 console.log(`\nPublishing Aesthetic Computer Desktop v${version}`); 163 console.log(` Bucket: ${BUCKET}`); 164 console.log(` Silo: ${SILO_URL}`); 165 if (dryRun) console.log(" [DRY RUN - no actual uploads]"); 166 167 const files = collectFiles(); 168 if (files.length === 0) { 169 console.error("\nNo files to upload. Build first with: npm run build:linux"); 170 process.exit(1); 171 } 172 173 console.log(`\nFound ${files.length} files to upload:`); 174 for (const f of files) console.log(` ${f.name} (${f.isManifest ? "manifest" : "binary"})`); 175 176 // Upload all files 177 console.log("\nUploading to DigitalOcean Spaces..."); 178 for (const file of files) { 179 await uploadFile(file); 180 } 181 182 // Parse manifests to build platform metadata 183 const platforms = {}; 184 for (const file of files.filter((f) => f.isManifest)) { 185 const platform = platformFromManifest(file.name); 186 if (!platform) continue; 187 const content = fs.readFileSync(file.path, "utf8"); 188 const parsed = parseYml(content); 189 if (parsed.path) { 190 // Get size from actual file on disk (yml may not have top-level size) 191 const binaryPath = path.join(distDir, parsed.path); 192 const size = parsed.size || (fs.existsSync(binaryPath) ? fs.statSync(binaryPath).size : 0); 193 platforms[platform] = { 194 filename: parsed.path, 195 size, 196 sha512: parsed.sha512 || "", 197 url: `${BASE_URL}/${parsed.path}`, 198 }; 199 } 200 } 201 202 if (Object.keys(platforms).length === 0) { 203 console.warn("\nNo manifest files found - skipping silo registration."); 204 console.log("Upload the latest-*.yml files to register."); 205 return; 206 } 207 208 // Register with silo 209 await registerWithSilo(platforms); 210 211 console.log(`\nDone! v${version} published.`); 212 console.log(` Download: ${BASE_URL}/`); 213 console.log(` Silo API: ${SILO_URL}/desktop/latest`); 214} 215 216main().catch((err) => { 217 console.error("\nPublish failed:", err.message); 218 process.exit(1); 219});