| 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; |
|
|
| |
| 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; |
| } |
| |
| return https |
| .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { |
| const redirectStatus = redirectResponse.statusCode; |
| const contentType = redirectResponse.headers['content-type'] || ''; |
| |
| 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', |
| }); |
| }); |
| } |
|
|
| |
| 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; |
| } 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; |
|
|
| |
| 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; |
| } |
| |
| 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'); |
|
|
| |
| if (!fs.existsSync(tempDir)) { |
| fs.mkdirSync(tempDir, { recursive: true }); |
| } |
|
|
| |
| 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}`); |
|
|
| |
| |
| 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`); |
|
|
| |
| 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'}` |
| ); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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', |
| }; |
| } |
|
|
| |
| const releasesData = await fetchExistingReleases(); |
|
|
| const newRelease = { |
| version: VERSION, |
| date: new Date().toISOString(), |
| assets, |
| githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`, |
| }; |
|
|
| |
| releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION); |
|
|
| |
| releasesData.releases.unshift(newRelease); |
| releasesData.latestVersion = VERSION; |
|
|
| |
| 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); |
| }); |
|
|