const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); const fs = require('fs'); const path = require('path'); const https = require('https'); const { pipeline } = require('stream/promises'); const s3Client = new S3Client({ region: 'auto', endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, }, }); const BUCKET = process.env.R2_BUCKET_NAME; const PUBLIC_URL = process.env.R2_PUBLIC_URL; const VERSION = process.env.RELEASE_VERSION; const RELEASE_TAG = process.env.RELEASE_TAG || `v${VERSION}`; const GITHUB_REPO = process.env.GITHUB_REPOSITORY; async function fetchExistingReleases() { try { const response = await s3Client.send( new GetObjectCommand({ Bucket: BUCKET, Key: 'releases.json', }) ); const body = await response.Body.transformToString(); return JSON.parse(body); } catch (error) { if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { console.log('No existing releases.json found, creating new one'); return { latestVersion: null, releases: [] }; } throw error; } } async function uploadFile(localPath, r2Key, contentType) { const fileBuffer = fs.readFileSync(localPath); const stats = fs.statSync(localPath); await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: r2Key, Body: fileBuffer, ContentType: contentType, }) ); console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`); return stats.size; } function findArtifacts(dir, pattern) { if (!fs.existsSync(dir)) return []; const files = fs.readdirSync(dir); return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f)); } async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { const result = await new Promise((resolve, reject) => { const request = https.get(url, { timeout: 10000 }, (response) => { const statusCode = response.statusCode; // Follow redirects if ( statusCode === 302 || statusCode === 301 || statusCode === 307 || statusCode === 308 ) { const redirectUrl = response.headers.location; response.destroy(); if (!redirectUrl) { resolve({ accessible: false, statusCode, error: 'Redirect without location header', }); return; } // Follow the redirect URL return https .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { const redirectStatus = redirectResponse.statusCode; const contentType = redirectResponse.headers['content-type'] || ''; // Check if it's actually a file (zip/tar.gz) and not HTML const isFile = contentType.includes('application/zip') || contentType.includes('application/gzip') || contentType.includes('application/x-gzip') || contentType.includes('application/x-tar') || redirectUrl.includes('.zip') || redirectUrl.includes('.tar.gz'); const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile; redirectResponse.destroy(); resolve({ accessible: isGood, statusCode: redirectStatus, finalUrl: redirectUrl, contentType, }); }) .on('error', (error) => { resolve({ accessible: false, statusCode, error: error.message, }); }) .on('timeout', function () { this.destroy(); resolve({ accessible: false, statusCode, error: 'Timeout following redirect', }); }); } // Check if status is good (200-299 range) and it's actually a file const contentType = response.headers['content-type'] || ''; const isFile = contentType.includes('application/zip') || contentType.includes('application/gzip') || contentType.includes('application/x-gzip') || contentType.includes('application/x-tar') || url.includes('.zip') || url.includes('.tar.gz'); const isGood = statusCode >= 200 && statusCode < 300 && isFile; response.destroy(); resolve({ accessible: isGood, statusCode, contentType }); }); request.on('error', (error) => { resolve({ accessible: false, statusCode: null, error: error.message, }); }); request.on('timeout', () => { request.destroy(); resolve({ accessible: false, statusCode: null, error: 'Request timeout', }); }); }); if (result.accessible) { if (attempt > 0) { console.log( `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})` ); } else { console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`); } return result.finalUrl || url; // Return the final URL (after redirects) if available } else { const errorMsg = result.error ? ` - ${result.error}` : ''; const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : ''; const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : ''; console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`); } } catch (error) { console.log(`✗ URL ${url} check failed: ${error.message}`); } if (attempt < maxRetries - 1) { const delay = initialDelay * Math.pow(2, attempt); console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw new Error(`URL ${url} is not accessible after ${maxRetries} attempts`); } async function downloadFromGitHub(url, outputPath) { return new Promise((resolve, reject) => { const request = https.get(url, { timeout: 30000 }, (response) => { const statusCode = response.statusCode; // Follow redirects (all redirect types) if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) { const redirectUrl = response.headers.location; response.destroy(); if (!redirectUrl) { reject(new Error(`Redirect without location header for ${url}`)); return; } // Resolve relative redirects const finalRedirectUrl = redirectUrl.startsWith('http') ? redirectUrl : new URL(redirectUrl, url).href; console.log(` Following redirect: ${finalRedirectUrl}`); return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject); } if (statusCode !== 200) { response.destroy(); reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`)); return; } const fileStream = fs.createWriteStream(outputPath); response.pipe(fileStream); fileStream.on('finish', () => { fileStream.close(); resolve(); }); fileStream.on('error', (error) => { response.destroy(); reject(error); }); }); request.on('error', reject); request.on('timeout', () => { request.destroy(); reject(new Error(`Request timeout for ${url}`)); }); }); } async function main() { const artifactsDir = 'artifacts'; const tempDir = path.join(artifactsDir, 'temp'); // Create temp directory for downloaded GitHub archives if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } // Download source archives from GitHub const githubZipUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.zip`; const githubTarGzUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.tar.gz`; const sourceZipPath = path.join(tempDir, `automaker-${VERSION}.zip`); const sourceTarGzPath = path.join(tempDir, `automaker-${VERSION}.tar.gz`); console.log(`Waiting for source archives to be available on GitHub...`); console.log(` ZIP: ${githubZipUrl}`); console.log(` TAR.GZ: ${githubTarGzUrl}`); // Wait for archives to be accessible with exponential backoff // This returns the final URL after following redirects const finalZipUrl = await checkUrlAccessible(githubZipUrl); const finalTarGzUrl = await checkUrlAccessible(githubTarGzUrl); console.log(`Downloading source archives from GitHub...`); await downloadFromGitHub(finalZipUrl, sourceZipPath); await downloadFromGitHub(finalTarGzUrl, sourceTarGzPath); console.log(`Downloaded source archives successfully`); // Find all artifacts const artifacts = { windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/), macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/), macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/), linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/), sourceZip: [sourceZipPath], sourceTarGz: [sourceTarGzPath], }; console.log('Found artifacts:'); for (const [platform, files] of Object.entries(artifacts)) { console.log( ` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}` ); } // Upload each artifact to R2 const assets = {}; const contentTypes = { windows: 'application/x-msdownload', macos: 'application/x-apple-diskimage', macosArm: 'application/x-apple-diskimage', linux: 'application/x-executable', sourceZip: 'application/zip', sourceTarGz: 'application/gzip', }; for (const [platform, files] of Object.entries(artifacts)) { if (files.length === 0) { console.warn(`Warning: No artifact found for ${platform}`); continue; } // Use the first matching file for each platform const localPath = files[0]; const filename = path.basename(localPath); const r2Key = `releases/${VERSION}/${filename}`; const size = await uploadFile(localPath, r2Key, contentTypes[platform]); assets[platform] = { url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`, filename, size, arch: platform === 'macosArm' ? 'arm64' : platform === 'sourceZip' || platform === 'sourceTarGz' ? 'source' : 'x64', }; } // Fetch and update releases.json const releasesData = await fetchExistingReleases(); const newRelease = { version: VERSION, date: new Date().toISOString(), assets, githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`, }; // Remove existing entry for this version if re-running releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION); // Prepend new release releasesData.releases.unshift(newRelease); releasesData.latestVersion = VERSION; // Upload updated releases.json await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: 'releases.json', Body: JSON.stringify(releasesData, null, 2), ContentType: 'application/json', CacheControl: 'public, max-age=60', }) ); console.log('Successfully updated releases.json'); console.log(`Latest version: ${VERSION}`); console.log(`Total releases: ${releasesData.releases.length}`); } main().catch((err) => { console.error('Failed to upload to R2:', err); process.exit(1); });