const fs = require('fs'); const path = require('path'); const https = require('https'); const { execSync } = require('child_process'); const crypto = require('crypto'); const UpdateClient = require('./update-client'); const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome'; const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH; const TOKEN = process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN; if (!TOKEN) { console.error('Error: GITLAB_TOKEN or VYLA_GITLAB_TOKEN environment variable is required.'); process.exit(1); } function incrementVersion() { const pkgPath = path.join(__dirname, 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); let [major, minor, patch] = (pkg.version || '1.0.0').split('.').map(Number); patch = (patch || 0) + 1; const newVersion = `${major}.${minor}.${patch}`; pkg.version = newVersion; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); const tauriPath = path.join(__dirname, 'src-tauri', 'tauri.conf.json'); if (fs.existsSync(tauriPath)) { const tauriConf = JSON.parse(fs.readFileSync(tauriPath, 'utf8')); tauriConf.version = newVersion; fs.writeFileSync(tauriPath, JSON.stringify(tauriConf, null, 4) + '\n', 'utf8'); } const cargoPath = path.join(__dirname, 'src-tauri', 'Cargo.toml'); if (fs.existsSync(cargoPath)) { let cargo = fs.readFileSync(cargoPath, 'utf8'); cargo = cargo.replace(/^version\s*=\s*".*"/m, `version = "${newVersion}"`); fs.writeFileSync(cargoPath, cargo, 'utf8'); } return newVersion; } function uploadFileToGitLabPackage(filePath, packageName, version, fileName) { return new Promise((resolve, reject) => { const stat = fs.statSync(filePath); const url = `https://gitlab.com/api/v4/projects/${PROJECT_ID}/packages/generic/${packageName}/${version}/${encodeURIComponent(fileName)}`; const urlObj = new URL(url); const req = https.request({ hostname: urlObj.hostname, port: 443, path: urlObj.pathname, method: 'PUT', headers: { 'PRIVATE-TOKEN': TOKEN, 'Content-Type': 'application/octet-stream', 'Content-Length': stat.size } }, (res) => { let body = ''; res.on('data', chunk => body += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(url); } else { reject(new Error(`Failed to upload ${fileName} (HTTP ${res.statusCode}): ${body}`)); } }); }); req.on('error', reject); const fileStream = fs.createReadStream(filePath); fileStream.on('error', reject); fileStream.pipe(req); }); } function createGitLabRelease(tag, releaseLinks) { const sendRequest = (method, reqPath, data = null) => { return new Promise((resolve, reject) => { const options = { hostname: 'gitlab.com', port: 443, path: reqPath, method: method, headers: { 'PRIVATE-TOKEN': TOKEN, 'Content-Type': 'application/json' } }; let postData = ''; if (data) { postData = JSON.stringify(data); options.headers['Content-Length'] = Buffer.byteLength(postData); } const req = https.request(options, (res) => { let body = ''; res.on('data', chunk => body += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { resolve(body ? JSON.parse(body) : {}); } catch (e) { resolve({}); } } else { reject(new Error(`HTTP ${res.statusCode}: ${body}`)); } }); }); req.on('error', reject); if (data) req.write(postData); req.end(); }); }; let currentRef = 'main'; try { currentRef = execSync('git rev-parse HEAD').toString().trim(); } catch (e) { } const releaseData = { name: `Vyla Home Release ${tag}`, tag_name: tag, ref: currentRef, description: `Automated Multi-Platform Release ${tag}`, assets: { links: releaseLinks } }; return sendRequest('POST', `/api/v4/projects/${PROJECT_ID}/releases`, releaseData) .catch(err => { if (err.message.includes('HTTP 409')) { console.log(`[CI Sync] Release ${tag} already exists. Appending our OS-specific files to it...`); let promiseChain = Promise.resolve(); const linksPath = `/api/v4/projects/${PROJECT_ID}/releases/${encodeURIComponent(tag)}/assets/links`; const hasInstaller = releaseLinks.some((l) => l.link_type === 'package'); for (const link of releaseLinks) { promiseChain = promiseChain.then(async () => { const current = await sendRequest('GET', linksPath); const clash = (Array.isArray(current) ? current : []).find((l) => l.name === link.name); if (clash && link.name === 'manifest.json' && hasInstaller) { await sendRequest('DELETE', `${linksPath}/${clash.id}`); } else if (clash) { console.log(`[CI Sync] Asset link '${link.name}' is already attached. Skipping.`); return; } await sendRequest('POST', linksPath, { name: link.name, url: link.url, link_type: link.link_type }); }); } return promiseChain.then(() => ({ name: `Vyla Home Release ${tag}`, tag_name: tag })); } throw err; }); } function injectDiscordConfig() { const envPath = path.join(__dirname, 'config', 'env.json'); const cfg = fs.existsSync(envPath) ? JSON.parse(fs.readFileSync(envPath, 'utf8')) : {}; const tmdb = process.env.TMDB_API_KEY || process.env.TMDB_KEY || '3f6703f3e2a0b2b40fe4fbb3e1546244'; const clientId = process.env.DISCORD_CLIENT_ID || ''; cfg.discord = cfg.discord || {}; if (tmdb) { cfg.discord.tmdbApiKey = tmdb; } else { console.log('[WARNING] No TMDB_API_KEY or TMDB_KEY set. Discord posters will fall back to page data.'); } if (clientId) cfg.discord.clientId = clientId; fs.mkdirSync(path.dirname(envPath), { recursive: true }); fs.writeFileSync(envPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); } async function main() { const isFullBuild = process.argv.includes('--installer'); const customTag = process.argv.find(arg => arg.startsWith('--tag='))?.split('=')[1]; const version = customTag || `v${incrementVersion()}`; console.log(`Building Vyla Home Release (${version})`); injectDiscordConfig(); console.log('[1/4] Building update payload and manifest...'); execSync('node build-pipeline.js', { stdio: 'inherit', env: { ...process.env, RELEASE_VERSION: version } }); const payloadPath = path.join(__dirname, 'update-payload.zip'); const manifestPath = path.join(__dirname, 'manifest.json'); if (!fs.existsSync(payloadPath) || !fs.existsSync(manifestPath)) { throw new Error('update-payload.zip or manifest.json not found.'); } let installerPath = null; let installerUploadName = null; if (isFullBuild) { console.log('Ensuring bundled Node.js runtime...'); await ensureBundledNode(); await ensureWebView2Loader(); const bundleDir = path.join(__dirname, 'src-tauri', 'target', 'release', 'bundle'); if (fs.existsSync(bundleDir)) { fs.rmSync(bundleDir, { recursive: true, force: true }); } console.log('Compiling native Tauri application...'); execSync('npm run build', { stdio: 'inherit' }); const targetVersion = version.startsWith('v') ? version.substring(1) : version; if (process.platform === 'win32') { const nsisDir = path.join(__dirname, 'src-tauri', 'target', 'release', 'bundle', 'nsis'); if (fs.existsSync(nsisDir)) { const files = fs.readdirSync(nsisDir).filter(f => f.endsWith('.exe') && f.includes(targetVersion)); if (files.length > 0) { installerPath = path.join(nsisDir, files[0]); installerUploadName = files[0].replace(/\s+/g, '-'); } } } else { const dmgDir = path.join(__dirname, 'src-tauri', 'target', 'release', 'bundle', 'dmg'); console.log(`Checking for Mac installer in: ${dmgDir}`); if (fs.existsSync(dmgDir)) { const files = fs.readdirSync(dmgDir).filter(f => f.endsWith('.dmg') && f.includes(targetVersion)); if (files.length > 0) { installerPath = path.join(dmgDir, files[0]); installerUploadName = files[0].replace(/\s+/g, '-'); console.log(`Found Mac installer: ${installerUploadName}`); } else { console.log(`[WARNING] No .dmg found containing version ${targetVersion}`); } } } } if (installerPath && installerUploadName && process.platform === 'win32') { const signingKey = process.env.VYLA_UPDATE_SIGNING_KEY; if (!signingKey) { throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the installer entry.'); } const installerBuf = fs.readFileSync(installerPath); const signedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); signedManifest.installer = { file: installerUploadName, sha256: crypto.createHash('sha256').update(installerBuf).digest('hex'), size: installerBuf.length, version: version.replace(/^v/, '') }; delete signedManifest.signature; signedManifest.signature = crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(signedManifest)), signingKey.replace(/\\n/g, '\n')).toString('base64'); fs.writeFileSync(manifestPath, JSON.stringify(signedManifest, null, 2), 'utf8'); } console.log('[2/4] Uploading artifacts to GitLab Package Registry...'); const timestamp = Date.now(); console.log('Uploading update-payload.zip...'); const payloadUrl = await uploadFileToGitLabPackage(payloadPath, 'vyla-home', version, `update-payload-${timestamp}.zip`); const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); for (const [name, c] of Object.entries(manifestData.components || {})) { console.log(`Uploading ${c.file}...`); c.url = await uploadFileToGitLabPackage(path.join(__dirname, 'release-assets', c.file), 'vyla-home', version, `${name}-${timestamp}.zip`); if (!c.url || !c.url.includes('/packages/generic/vyla-home/')) { throw new Error(`Upload for ${name} did not return a package URL.`); } } const signingKeyPem = process.env.VYLA_UPDATE_SIGNING_KEY; if (!signingKeyPem) { throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the update manifest.'); } delete manifestData.signature; manifestData.signature = crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(manifestData)), signingKeyPem.replace(/\\n/g, '\n')).toString('base64'); fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2), 'utf8'); console.log('Uploading manifest.json...'); const manifestUrl = await uploadFileToGitLabPackage(manifestPath, 'vyla-home', version, `manifest-${timestamp}.json`); const releaseLinks = [ { name: 'update-payload.zip', url: payloadUrl, link_type: 'other' }, { name: 'manifest.json', url: manifestUrl, link_type: 'other' } ]; if (installerPath && installerUploadName) { console.log(`Uploading installer ${installerUploadName}...`); const ext = path.extname(installerUploadName); const installerUnique = installerUploadName.replace(ext, `-${timestamp}${ext}`); const installerUrl = await uploadFileToGitLabPackage(installerPath, 'vyla-home', version, installerUnique); releaseLinks.push({ name: installerUploadName, url: installerUrl, link_type: 'package' }); } else { console.log(`[WARNING] No installer was selected for upload! (isFullBuild: ${isFullBuild})`); } console.log('[3/4] Publishing GitLab Release...'); await createGitLabRelease(version, releaseLinks); console.log(`[4/4] Release process successfully completed for ${version}!`); } function ensureBundledNode() { const os = require('os'); const binDir = path.join(__dirname, 'bin'); const isWin = os.platform() === 'win32'; const nodeExeName = isWin ? 'node.exe' : 'node'; const nodeExePath = path.join(binDir, nodeExeName); const nodeVersion = 'v22.14.0'; const versionMarker = path.join(binDir, `node-${nodeVersion}.marker`); if (fs.existsSync(nodeExePath) && fs.existsSync(versionMarker)) { return Promise.resolve(); } fs.mkdirSync(binDir, { recursive: true }); console.log(`Downloading standalone Node.js ${nodeVersion} runtime for bundling...`); return new Promise((resolve, reject) => { const download = (url, dest, cb) => { https.get(url, (res) => { if (res.statusCode === 302 || res.statusCode === 301) { return download(res.headers.location, dest, cb); } const fileStream = fs.createWriteStream(dest); res.pipe(fileStream); fileStream.on('finish', () => { fileStream.close(); cb(); }); }).on('error', reject); }; if (isWin) { const nodeUrl = `https://nodejs.org/dist/${nodeVersion}/win-x64/node.exe`; download(nodeUrl, nodeExePath, () => { fs.writeFileSync(versionMarker, nodeVersion, 'utf8'); resolve(); }); } else { const arch = os.arch() === 'arm64' ? 'arm64' : 'x64'; const platform = os.platform() === 'darwin' ? 'darwin' : 'linux'; const tarName = `node-${nodeVersion}-${platform}-${arch}`; const nodeUrl = `https://nodejs.org/dist/${nodeVersion}/${tarName}.tar.gz`; const tarPath = path.join(binDir, 'node.tar.gz'); download(nodeUrl, tarPath, () => { try { execSync(`tar -xzf node.tar.gz ${tarName}/bin/node`, { cwd: binDir }); const extractedNode = path.join(binDir, tarName, 'bin', 'node'); const finalNode = path.join(binDir, 'node'); fs.renameSync(extractedNode, finalNode); fs.rmSync(path.join(binDir, tarName), { recursive: true, force: true }); fs.unlinkSync(tarPath); fs.chmodSync(finalNode, 0o755); try { execSync(`xattr -d com.apple.quarantine "${finalNode}"`); } catch (_) { } fs.writeFileSync(versionMarker, nodeVersion, 'utf8'); resolve(); } catch (e) { reject(e); } }); } }); } function ensureWebView2Loader() { const os = require('os'); if (os.platform() !== 'win32') return Promise.resolve(); const releaseDir = path.join(__dirname, 'src-tauri', 'target', 'release'); const rootDllPath = path.join(__dirname, 'src-tauri', 'WebView2Loader.dll'); fs.mkdirSync(releaseDir, { recursive: true }); const targetDllPath = path.join(releaseDir, 'WebView2Loader.dll'); if (fs.existsSync(rootDllPath) && fs.existsSync(targetDllPath)) { return Promise.resolve(); } const AdmZip = require('adm-zip'); const tempNupkg = path.join(__dirname, 'temp-wv2.zip'); const nupkgUrl = 'https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/1.0.2903.40'; return new Promise((resolve, reject) => { const fileStream = fs.createWriteStream(tempNupkg); const request = (url) => { https.get(url, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { return request(res.headers.location); } res.pipe(fileStream); fileStream.on('finish', () => { fileStream.close(); try { const zip = new AdmZip(tempNupkg); const zipEntries = zip.getEntries(); const dllEntry = zipEntries.find(e => e.entryName.includes('x64/WebView2Loader.dll') || e.entryName.includes('x64\\WebView2Loader.dll') ); if (dllEntry) { const buffer = dllEntry.getData(); fs.writeFileSync(targetDllPath, buffer); fs.writeFileSync(rootDllPath, buffer); } try { fs.unlinkSync(tempNupkg); } catch (_) { } resolve(); } catch (e) { try { fs.unlinkSync(tempNupkg); } catch (_) { } reject(e); } }); }).on('error', (err) => { try { fs.unlinkSync(tempNupkg); } catch (_) { } reject(err); }); }; request(nupkgUrl); }); } main().catch(err => { console.error('Release failed:', err.message); process.exit(1); });