import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve(__dirname, '..'); const packageJson = JSON.parse( fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') ); const releaseDirArg = process.argv .find((arg) => arg.startsWith('--release-dir=')) ?.slice('--release-dir='.length); const releaseDir = releaseDirArg ? path.resolve(workspaceRoot, releaseDirArg) : path.join(workspaceRoot, 'dist/desktop/release'); const channelUrl = process.env.TRUEGROWTH_UPDATE_FEED_URL || 'https://truegrowth.benchu.cloud/desktop/stable/'; const channelHost = new URL(channelUrl).host; const platformArg = process.argv .find((arg) => arg.startsWith('--platform=')) ?.slice('--platform='.length) || 'all'; function readFileIfExists(filePath) { return fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; } function sha256(filePath) { const hash = crypto.createHash('sha256'); hash.update(fs.readFileSync(filePath)); return hash.digest('hex'); } function statArtifact(filePath) { const stats = fs.statSync(filePath); return { name: path.basename(filePath), size: stats.size, sha256: sha256(filePath), url: new URL(path.basename(filePath), channelUrl).toString(), }; } function readYamlMetadata(fileName) { const filePath = path.join(releaseDir, fileName); const content = readFileIfExists(filePath); if (!content) { return null; } return yaml.load(content.toString('utf8')); } function assertMetadataUrl(metadata, fileName) { if (!metadata) { throw new Error(`Missing required update metadata: ${fileName}`); } const files = Array.isArray(metadata.files) ? metadata.files : []; const urls = [metadata.path, ...files.map((file) => file?.url)].filter( Boolean ); if (!urls.length) { throw new Error(`${fileName} does not contain any update file URLs.`); } for (const url of urls) { const parsed = new URL(String(url), channelUrl); if (parsed.host !== channelHost) { throw new Error( `${fileName} points to ${parsed.host}; expected ${channelHost}.` ); } } } function listReleaseArtifacts() { if (!fs.existsSync(releaseDir)) { throw new Error( `Desktop release directory is missing: ${releaseDir}\nRun pnpm desktop:build first.` ); } const escapedVersion = packageJson.version.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); const macArtifactPattern = new RegExp( `^TrueGrowth-${escapedVersion}-(arm64|x64)(-mac\\.zip|\\.dmg)(\\.blockmap)?$`, 'i' ); const winArtifactPattern = new RegExp( `^TrueGrowth-${escapedVersion}-x64-(Setup|Portable)\\.exe(\\.blockmap)?$`, 'i' ); return fs .readdirSync(releaseDir) .filter((name) => { const isMacMetadata = name === 'latest-mac.yml'; const isWinMetadata = name === 'latest.yml'; const isMacArtifact = macArtifactPattern.test(name); const isWinArtifact = winArtifactPattern.test(name); if (platformArg === 'mac') { return isMacMetadata || isMacArtifact; } if (platformArg === 'win') { return isWinMetadata || isWinArtifact; } return isMacMetadata || isWinMetadata || isMacArtifact || isWinArtifact; }) .sort(); } function classifyArtifact(name) { if (name === 'latest.yml') { return 'windows-update-metadata'; } if (name === 'latest-mac.yml') { return 'mac-update-metadata'; } if (/\.dmg$/i.test(name)) { return 'mac-installer'; } if (/-mac\.zip$/i.test(name)) { return 'mac-auto-update'; } if (/-Setup\.exe$/i.test(name)) { return 'windows-installer'; } if (/-Portable\.exe$/i.test(name)) { return 'windows-portable-manual'; } if (/\.blockmap$/i.test(name)) { return 'differential-update'; } return 'release-file'; } function writeReleaseManifest(artifacts) { const generatedAt = new Date().toISOString(); const releaseFiles = artifacts.map((name) => { const filePath = path.join(releaseDir, name); const base = statArtifact(filePath); return { ...base, role: classifyArtifact(name), }; }); const manifest = { app: 'TrueGrowth', version: packageJson.version, channel: 'stable', channelUrl, generatedAt, files: releaseFiles, cachePolicy: { metadata: ['latest.yml', 'latest-mac.yml', 'releases.json'], metadataHeader: 'Cache-Control: no-store, no-cache, must-revalidate', artifactsHeader: 'Cache-Control: public, max-age=31536000, immutable', }, onePanelUploadRoot: '/desktop/stable/', signingGate: { macos: 'Developer ID signed and notarized before customer release', windows: 'Authenticode signed before customer release', unsignedBuilds: 'internal testing only', }, }; const outputPath = path.join(releaseDir, 'releases.json'); fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); return manifest; } function printUploadGuide(manifest) { console.log('\n[desktop:update] Upload these files to 1Panel static root:'); for (const file of manifest.files) { console.log(`- ${file.name} -> ${manifest.onePanelUploadRoot}${file.name}`); } if (!manifest.files.some((file) => file.name === 'releases.json')) { console.log('- releases.json -> /desktop/stable/releases.json'); } console.log('\n[desktop:update] Nginx cache guidance:'); console.log( '- latest.yml, latest-mac.yml, releases.json: Cache-Control no-store' ); console.log('- installers, zip, blockmap: long cache with immutable'); console.log(`\n[desktop:update] Channel: ${manifest.channelUrl}`); } function main() { const artifacts = listReleaseArtifacts(); const latest = readYamlMetadata('latest.yml'); const latestMac = readYamlMetadata('latest-mac.yml'); if (platformArg === 'all' || platformArg === 'win') { assertMetadataUrl(latest, 'latest.yml'); } if (platformArg === 'all' || platformArg === 'mac') { assertMetadataUrl(latestMac, 'latest-mac.yml'); } const manifest = writeReleaseManifest(artifacts); printUploadGuide(manifest); } main();