347 lines
13 KiB
JavaScript
347 lines
13 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)
|
|
*
|
|
* SFTP notes:
|
|
* - SFTP requires host verification. Provide one of:
|
|
* SFTP_KNOWN_HOSTS=/path/to/known_hosts
|
|
* SFTP_HOST_PUBKEY_SHA256=base64_sha256_fingerprint
|
|
* SFTP_HOST_PUBKEY_MD5=aa:bb:cc:... (legacy)
|
|
* If not provided and no default known_hosts is found, curl will fail with
|
|
* "Couldn't find a known_hosts file".
|
|
*/
|
|
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 || '/';
|
|
|
|
// SFTP host verification options (optional)
|
|
const sftpKnownHosts = process.env.SFTP_KNOWN_HOSTS || process.env.FTP_SSH_KNOWN_HOSTS;
|
|
const sftpHostPubSha256 = process.env.SFTP_HOST_PUBKEY_SHA256 || process.env.FTP_SSH_HOST_PUBKEY_SHA256;
|
|
const sftpHostPubMd5 = process.env.SFTP_HOST_PUBKEY_MD5 || process.env.FTP_SSH_HOST_PUBKEY_MD5;
|
|
|
|
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);
|
|
|
|
// Determine explicit vs implicit FTPS handling
|
|
function asBool(v) {
|
|
const s = String(v || '').toLowerCase();
|
|
return s === 'true' || s === '1' || s === 'yes' || s === 'on';
|
|
}
|
|
const ftpsMode = (process.env.FTPS_MODE || '').toLowerCase(); // 'explicit' | 'implicit' | ''
|
|
let useExplicitFtps = false;
|
|
if (protocol === 'ftps') {
|
|
if (ftpsMode === 'explicit') useExplicitFtps = true;
|
|
else if (ftpsMode === 'implicit') useExplicitFtps = false;
|
|
else if (String(port || '') === '' || String(port) === '21') useExplicitFtps = true; // default to explicit on port 21
|
|
}
|
|
|
|
// Construct base URL for FTP/SFTP upload target
|
|
const scheme = (protocol === 'ftps' && useExplicitFtps) ? 'ftp' : protocol;
|
|
const baseUrl = `${scheme}://${host}${port ? `:${port}` : ''}${remoteDir}`;
|
|
|
|
// Prepare common curl flags
|
|
const curlBase = ['curl', '--fail', '--ftp-create-dirs'];
|
|
// Optional passive mode
|
|
if (String(process.env.FTP_PASSIVE || '').toLowerCase() === 'true') {
|
|
curlBase.push('--ftp-pasv');
|
|
}
|
|
// Optional TLS min version
|
|
const tlsMin = String(process.env.TLS_MIN || '').trim();
|
|
if (tlsMin === '1.2') curlBase.push('--tlsv1.2');
|
|
else if (tlsMin === '1.3') curlBase.push('--tlsv1.3');
|
|
if (protocol === 'ftps' && useExplicitFtps) {
|
|
// Explicit FTPS (FTPES): connect plain on port 21 and then upgrade to TLS
|
|
// Modern curl prefers --ssl-reqd; keep compatibility with older --ftp-ssl-reqd if available
|
|
curlBase.push('--ssl-reqd');
|
|
// Security knobs: either pin the server cert public key, supply a custom CA bundle, or allow insecure as last resort
|
|
const pinned = process.env.TLS_PINNED_PUBKEY;
|
|
const cacert = process.env.TLS_CACERT;
|
|
const insecure = String(process.env.TLS_INSECURE || '').toLowerCase() === 'true';
|
|
if (pinned) curlBase.push('--pinnedpubkey', JSON.stringify(pinned));
|
|
if (cacert) curlBase.push('--cacert', JSON.stringify(cacert));
|
|
if (insecure) curlBase.push('--insecure');
|
|
}
|
|
// Detect curl option support dynamically to stay compatible with older curl versions
|
|
function curlSupports(opt) {
|
|
try {
|
|
const { spawnSync } = require('child_process');
|
|
const res = spawnSync('curl', ['--help'], { encoding: 'utf8' });
|
|
const out = (res.stdout || '') + (res.stderr || '');
|
|
return out.includes(opt);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
if (protocol === 'sftp') {
|
|
const hasKnownHostsOpt = curlSupports('--knownhosts');
|
|
const hasHostpubSha256Opt = curlSupports('--hostpubsha256');
|
|
const hasHostpubMd5Opt = curlSupports('--hostpubmd5');
|
|
|
|
if (sftpKnownHosts && hasKnownHostsOpt) {
|
|
curlBase.push('--knownhosts', JSON.stringify(sftpKnownHosts));
|
|
} else if (sftpKnownHosts && !hasKnownHostsOpt) {
|
|
console.warn('SFTP: curl does not support --knownhosts; consider using SFTP_HOST_PUBKEY_SHA256 instead.');
|
|
}
|
|
|
|
if (sftpHostPubSha256 && hasHostpubSha256Opt) {
|
|
curlBase.push('--hostpubsha256', JSON.stringify(sftpHostPubSha256));
|
|
} else if (sftpHostPubSha256 && !hasHostpubSha256Opt) {
|
|
console.warn('SFTP: curl does not support --hostpubsha256; try updating curl.');
|
|
}
|
|
|
|
if (!sftpHostPubSha256 && sftpHostPubMd5 && hasHostpubMd5Opt) {
|
|
curlBase.push('--hostpubmd5', JSON.stringify(sftpHostPubMd5));
|
|
} else if (sftpHostPubMd5 && !hasHostpubMd5Opt) {
|
|
console.warn('SFTP: curl does not support --hostpubmd5; try updating curl.');
|
|
}
|
|
|
|
if (!curlBase.some(f => typeof f === 'string' && f.startsWith('--hostpub')) && !curlBase.includes('--knownhosts')) {
|
|
console.warn('SFTP: no host verification flags provided; relying on default known_hosts (may fail).');
|
|
}
|
|
}
|
|
|
|
// 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 ${scheme}://${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 = [
|
|
...curlBase,
|
|
`--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 = [
|
|
...curlBase,
|
|
`--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 = [
|
|
...curlBase,
|
|
`--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);
|
|
}
|