265 lines
		
	
	
	
		
			9.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			265 lines
		
	
	
	
		
			9.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
#!/usr/bin/env node
 | 
						|
/*
 | 
						|
 * Uploads the latest signed release artifacts in releases/<version>/ via FTP/FTPS/SFTP using curl.
 | 
						|
 * Reads configuration from .env (auto-loaded) or process.env.
 | 
						|
 * Env vars:
 | 
						|
 *   FTP_PROTOCOL=ftp|ftps|sftp (default: ftp)
 | 
						|
 *   FTP_HOST=example.com (required)
 | 
						|
 *   FTP_PORT=21 (optional)
 | 
						|
 *   FTP_USER=username (required)
 | 
						|
 *   FTP_PASS=password (required)
 | 
						|
 *   FTP_REMOTE_DIR=/remote/path (required)
 | 
						|
 */
 | 
						|
const fs = require('fs');
 | 
						|
const path = require('path');
 | 
						|
const { execSync } = require('child_process');
 | 
						|
 | 
						|
const root = path.join(__dirname, '..');
 | 
						|
 | 
						|
// Auto-load .env if present
 | 
						|
(() => {
 | 
						|
  try {
 | 
						|
    const envPath = path.join(root, '.env');
 | 
						|
    if (fs.existsSync(envPath)) {
 | 
						|
      const content = fs.readFileSync(envPath, 'utf8');
 | 
						|
      for (const rawLine of content.split(/\r?\n/)) {
 | 
						|
        const line = rawLine.trim();
 | 
						|
        if (!line || line.startsWith('#')) continue;
 | 
						|
        const cleaned = line.startsWith('export ')
 | 
						|
          ? line.slice('export '.length).trim()
 | 
						|
          : line;
 | 
						|
        const eq = cleaned.indexOf('=');
 | 
						|
        if (eq === -1) continue;
 | 
						|
        const key = cleaned.slice(0, eq).trim();
 | 
						|
        let val = cleaned.slice(eq + 1).trim();
 | 
						|
        if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
 | 
						|
          val = val.slice(1, -1);
 | 
						|
        }
 | 
						|
        if (!(key in process.env)) process.env[key] = val;
 | 
						|
      }
 | 
						|
      console.log('Loaded environment from .env');
 | 
						|
    }
 | 
						|
  } catch (e) {
 | 
						|
    console.warn('Warning: Failed to load .env:', e.message);
 | 
						|
  }
 | 
						|
})();
 | 
						|
 | 
						|
function mask(str) {
 | 
						|
  if (!str) return str;
 | 
						|
  return '***';
 | 
						|
}
 | 
						|
 | 
						|
function run(cmd, secrets = []) {
 | 
						|
  let shown = cmd;
 | 
						|
  for (const s of secrets) {
 | 
						|
    if (s) shown = shown.split(String(s)).join('***');
 | 
						|
  }
 | 
						|
  console.log('> ' + shown);
 | 
						|
  execSync(cmd, { stdio: 'inherit' });
 | 
						|
}
 | 
						|
 | 
						|
const pkg = require(path.join(root, 'package.json'));
 | 
						|
const manifest = require(path.join(root, 'manifest.json'));
 | 
						|
const version = pkg.version;
 | 
						|
if (!version) {
 | 
						|
  console.error('package.json is missing version');
 | 
						|
  process.exit(1);
 | 
						|
}
 | 
						|
 | 
						|
const artifactsDir = path.join(root, 'releases', version);
 | 
						|
if (!fs.existsSync(artifactsDir) || !fs.statSync(artifactsDir).isDirectory()) {
 | 
						|
  console.error(`Artifacts directory not found: ${artifactsDir}. Run npm run release:sign first.`);
 | 
						|
  process.exit(1);
 | 
						|
}
 | 
						|
 | 
						|
const files = fs.readdirSync(artifactsDir).filter(f => fs.statSync(path.join(artifactsDir, f)).isFile());
 | 
						|
const xpis = files.filter(f => f.toLowerCase().endsWith('.xpi'));
 | 
						|
if (xpis.length === 0) {
 | 
						|
  console.error(`No .xpi artifacts found in ${artifactsDir}`);
 | 
						|
  process.exit(1);
 | 
						|
}
 | 
						|
const chosenXpi = xpis.sort()[0];
 | 
						|
if (files.length === 0) {
 | 
						|
  console.error(`No files found in ${artifactsDir}`);
 | 
						|
  process.exit(1);
 | 
						|
}
 | 
						|
 | 
						|
const protocol = (process.env.FTP_PROTOCOL || 'ftp').toLowerCase();
 | 
						|
const host = process.env.FTP_HOST;
 | 
						|
const port = process.env.FTP_PORT;
 | 
						|
const user = process.env.FTP_USER;
 | 
						|
const pass = process.env.FTP_PASS;
 | 
						|
let remoteDir = process.env.FTP_REMOTE_DIR || '/';
 | 
						|
 | 
						|
if (!host || !user || !pass || !remoteDir) {
 | 
						|
  console.error('Missing FTP config. Required: FTP_HOST, FTP_USER, FTP_PASS, FTP_REMOTE_DIR');
 | 
						|
  process.exit(1);
 | 
						|
}
 | 
						|
 | 
						|
// Normalize remoteDir
 | 
						|
if (!remoteDir.startsWith('/')) remoteDir = '/' + remoteDir;
 | 
						|
if (remoteDir.endsWith('/')) remoteDir = remoteDir.slice(0, -1);
 | 
						|
 | 
						|
// Construct base URL for FTP upload target
 | 
						|
const baseUrl = `${protocol}://${host}${port ? `:${port}` : ''}${remoteDir}`;
 | 
						|
 | 
						|
// Prepare or update self-hosted updates.json before upload
 | 
						|
function ensureUpdatesJson() {
 | 
						|
  try {
 | 
						|
    const updatesPath = path.join(root, 'releases', 'updates.json');
 | 
						|
    const examplePath = path.join(root, 'releases', 'updates.example.json');
 | 
						|
    let data;
 | 
						|
    if (fs.existsSync(updatesPath)) {
 | 
						|
      data = JSON.parse(fs.readFileSync(updatesPath, 'utf8'));
 | 
						|
    } else if (fs.existsSync(examplePath)) {
 | 
						|
      data = JSON.parse(fs.readFileSync(examplePath, 'utf8'));
 | 
						|
    } else {
 | 
						|
      data = { addons: {} };
 | 
						|
    }
 | 
						|
 | 
						|
    const addonId = manifest?.applications?.gecko?.id;
 | 
						|
    const updateUrl = manifest?.applications?.gecko?.update_url;
 | 
						|
    if (!addonId || !updateUrl) {
 | 
						|
      console.warn('Missing add-on id or update_url in manifest; skipping updates.json generation');
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Derive public base directory from update_url (strip trailing filename)
 | 
						|
    let updatesBaseDir = updateUrl.replace(/\/?[^/]*$/, '');
 | 
						|
    // Build public link to the uploaded XPI (artifact is placed directly under the base dir)
 | 
						|
    const publicLink = `${updatesBaseDir}/${encodeURIComponent(chosenXpi)}`;
 | 
						|
 | 
						|
    if (!data.addons) data.addons = {};
 | 
						|
    if (!data.addons[addonId]) data.addons[addonId] = { updates: [] };
 | 
						|
    const entry = { version: String(version), update_link: publicLink };
 | 
						|
    // Try to preserve existing strict_min_version if present in latest entry
 | 
						|
    const existingUpdates = data.addons[addonId].updates || [];
 | 
						|
    let strictMin = '91.0';
 | 
						|
    for (const u of existingUpdates) {
 | 
						|
      if (u?.applications?.gecko?.strict_min_version) {
 | 
						|
        strictMin = u.applications.gecko.strict_min_version;
 | 
						|
        break;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    entry.applications = { gecko: { strict_min_version: strictMin } };
 | 
						|
 | 
						|
    // Replace or append the entry for this version
 | 
						|
    const idx = existingUpdates.findIndex(u => String(u.version) === String(version));
 | 
						|
    if (idx >= 0) existingUpdates[idx] = entry; else existingUpdates.push(entry);
 | 
						|
    data.addons[addonId].updates = existingUpdates;
 | 
						|
 | 
						|
    fs.writeFileSync(updatesPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
 | 
						|
    console.log(`Prepared releases/updates.json for version ${version}`);
 | 
						|
  } catch (e) {
 | 
						|
    console.warn('Warning: Failed to update releases/updates.json:', e.message);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
ensureUpdatesJson();
 | 
						|
 | 
						|
console.log(`Uploading ${files.length} file(s) from ${artifactsDir} to ${protocol}://${host}${port ? `:${port}` : ''}${remoteDir}/`);
 | 
						|
 | 
						|
for (const file of files) {
 | 
						|
  const localPath = path.join(artifactsDir, file);
 | 
						|
  const url = `${baseUrl}/${encodeURIComponent(file)}`;
 | 
						|
  // --ftp-create-dirs ensures remote dirs are created; --fail fails on server errors.
 | 
						|
  const cmd = [
 | 
						|
    'curl',
 | 
						|
    '--fail',
 | 
						|
    '--ftp-create-dirs',
 | 
						|
    `--user`, `${user}:${pass}`,
 | 
						|
    '--upload-file', JSON.stringify(localPath),
 | 
						|
    JSON.stringify(url),
 | 
						|
  ].join(' ');
 | 
						|
  run(cmd, [user, pass]);
 | 
						|
}
 | 
						|
 | 
						|
// Upload a copy of the latest XPI under a stable filename for static linking
 | 
						|
try {
 | 
						|
  const latestAlias = 'archive-org-link-grabber-latest.xpi';
 | 
						|
  const localLatestSrc = path.join(artifactsDir, chosenXpi);
 | 
						|
  const latestUrl = `${baseUrl}/${latestAlias}`;
 | 
						|
  console.log(`Uploading latest alias ${latestAlias} -> ${chosenXpi}`);
 | 
						|
  const latestCmd = [
 | 
						|
    'curl',
 | 
						|
    '--fail',
 | 
						|
    '--ftp-create-dirs',
 | 
						|
    `--user`, `${user}:${pass}`,
 | 
						|
    '--upload-file', JSON.stringify(localLatestSrc),
 | 
						|
    JSON.stringify(latestUrl),
 | 
						|
  ].join(' ');
 | 
						|
  run(latestCmd, [user, pass]);
 | 
						|
} catch (e) {
 | 
						|
  console.warn('Warning: Failed to upload latest alias:', e.message);
 | 
						|
}
 | 
						|
 | 
						|
// Also upload updates.json (self-hosted updates manifest) alongside artifacts
 | 
						|
const updatesPath = path.join(root, 'releases', 'updates.json');
 | 
						|
if (fs.existsSync(updatesPath)) {
 | 
						|
  const updatesUrl = `${baseUrl}/updates.json`;
 | 
						|
  console.log(`Uploading updates.json to ${updatesUrl}`);
 | 
						|
  const cmd = [
 | 
						|
    'curl',
 | 
						|
    '--fail',
 | 
						|
    '--ftp-create-dirs',
 | 
						|
    `--user`, `${user}:${pass}`,
 | 
						|
    '--upload-file', JSON.stringify(updatesPath),
 | 
						|
    JSON.stringify(updatesUrl),
 | 
						|
  ].join(' ');
 | 
						|
  run(cmd, [user, pass]);
 | 
						|
} else {
 | 
						|
  console.warn('Warning: releases/updates.json not found; skipping upload.');
 | 
						|
}
 | 
						|
 | 
						|
console.log('Upload complete.');
 | 
						|
 | 
						|
// After upload, commit generated artifacts to the repo so changes are tracked.
 | 
						|
try {
 | 
						|
  const { spawnSync } = require('child_process');
 | 
						|
 | 
						|
  function runGit(args, opts = {}) {
 | 
						|
    const res = spawnSync('git', args, { stdio: 'inherit', cwd: root, ...opts });
 | 
						|
    if (res.error) throw res.error;
 | 
						|
    return res.status || 0;
 | 
						|
  }
 | 
						|
  function runGitOut(args, opts = {}) {
 | 
						|
    const res = spawnSync('git', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', cwd: root, ...opts });
 | 
						|
    if (res.error) throw res.error;
 | 
						|
    return { code: res.status || 0, out: (res.stdout || '').trim(), err: (res.stderr || '').trim() };
 | 
						|
  }
 | 
						|
 | 
						|
  console.log('Staging release artifacts for commit…');
 | 
						|
  // Stage updates.json and the current version folder; icons may have changed earlier, include them too.
 | 
						|
  runGit(['add', 'releases/updates.json']);
 | 
						|
  runGit(['add', '-f', path.join('releases', String(version))]);
 | 
						|
  runGit(['add', 'icons/icon-*.png']);
 | 
						|
 | 
						|
  const { out: staged } = runGitOut(['diff', '--cached', '--name-only']);
 | 
						|
  if (!staged) {
 | 
						|
    console.log('✓ Nothing to commit. Repository already up to date.');
 | 
						|
  } else {
 | 
						|
    const msg = `chore(release): publish v${version} artifacts`;
 | 
						|
    console.log(`Committing: ${msg}`);
 | 
						|
    const codeCommit = runGit(['commit', '-m', msg]);
 | 
						|
    if (codeCommit !== 0) {
 | 
						|
      console.warn('Warning: git commit exited with non-zero status.');
 | 
						|
    } else {
 | 
						|
      // Optional push if requested
 | 
						|
      const doPush = String(process.env.GIT_PUSH || '').toLowerCase() === 'true';
 | 
						|
      if (doPush) {
 | 
						|
        console.log('Pushing commit to upstream…');
 | 
						|
        const { code: upCode } = runGitOut(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
 | 
						|
        if (upCode === 0) {
 | 
						|
          runGit(['push']);
 | 
						|
        } else {
 | 
						|
          const { out: branch } = runGitOut(['rev-parse', '--abbrev-ref', 'HEAD']);
 | 
						|
          runGit(['push', '-u', 'origin', branch]);
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        console.log('✓ Commit created. Skipping git push (enable with GIT_PUSH=true).');
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
} catch (e) {
 | 
						|
  console.warn('Warning: Failed to create release commit:', e.message);
 | 
						|
}
 |