Files
Home/release.js
2026-11-06 13:27:55 -08:00

437 lines
18 KiB
JavaScript

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();
for (const link of releaseLinks) {
promiseChain = promiseChain.then(() => {
return sendRequest('POST', `/api/v4/projects/${PROJECT_ID}/releases/${encodeURIComponent(tag)}/assets/links`, {
name: link.name,
url: link.url,
link_type: link.link_type
}).catch(e => {
if (e.message.includes('already been taken') || e.message.includes('HTTP 409') || e.message.includes('HTTP 400')) {
console.log(`[CI Sync] Asset link '${link.name}' is already attached. Skipping.`);
return Promise.resolve();
}
throw e;
});
});
}
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);
});