#!/usr/bin/env node import { execSync } from 'child_process'; import { createHash } from 'crypto'; import { writeFileSync, statSync, createReadStream, existsSync } from 'fs'; import { resolve, basename } from 'path'; import { pathToFileURL } from 'url'; const ROOT = resolve(import.meta.dirname, '..'); const RELEASE_DIR = resolve(ROOT, 'release'); const PRODUCT_NAME = 'Multi-Hoster-Upload'; // --- CLI args --- export function parseReleaseArgs(args) { const version = Array.isArray(args) ? args[0] : ''; if (!/^\d+\.\d+\.\d+$/.test(version || '')) { throw new Error('Usage: node scripts/release_gitea.mjs --transport-tag [release notes] [--dry-run]'); } const transportTagIndex = args.indexOf('--transport-tag'); const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : ''; if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) { throw new Error('--transport-tag must match vX.Y.Z'); } const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]); const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' '); return { version, transportTag, notes, dryRun: args.includes('--dry-run') }; } export function createReleasePlan(options) { const releaseTitle = `${PRODUCT_NAME} v${options.version}`; const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`; const portableName = `${PRODUCT_NAME} ${options.version}.exe`; return { ...options, tag: options.transportTag, releaseTitle, releaseBody: options.notes || releaseTitle, setupName, portableName, expectedArtifacts: [setupName, portableName, 'latest.yml'], blockmapName: `${setupName}.blockmap` }; } export function resolveExistingReleaseId(plan, release) { const existingTitle = typeof release?.name === 'string' ? release.name : ''; if (existingTitle !== plan.releaseTitle) { throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`); } return release.id; } export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) { return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`; } let dryRun = false; // --- Helpers --- function run(cmd, opts = {}) { console.log(` $ ${cmd}`); if (dryRun && !opts.allowDry) { console.log(' [dry-run] skipped'); return ''; } const output = execSync(cmd, { cwd: ROOT, encoding: 'utf-8', stdio: opts.stdio || 'pipe' }); return typeof output === 'string' ? output.trim() : ''; } function resolveGiteaRemote() { const remotes = run('git remote -v', { allowDry: true }); for (const name of ['gitea', 'forgejo', 'origin']) { const match = remotes.match(new RegExp(`^${name}\\s+(\\S+)`, 'm')); if (match && match[1].includes('git.24-music.de')) return { name, url: match[1] }; } return null; } function resolveToken() { // Env var first const envToken = process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN; if (envToken) return envToken; // Try git credential helper try { const input = 'protocol=https\nhost=git.24-music.de\n\n'; const output = execSync('git credential fill', { input, encoding: 'utf-8', cwd: ROOT }); const match = output.match(/password=(.+)/); if (match) return match[1].trim(); } catch {} return null; } async function giteaApi(method, urlPath, token, body) { const base = process.env.GITEA_BASE_URL || 'https://git.24-music.de'; const url = `${base}${urlPath}`; const headers = { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }; const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = null; } if (!res.ok && res.status !== 409 && res.status !== 422) { throw new Error(`Gitea API ${res.status}: ${text.slice(0, 300)}`); } return { status: res.status, data: json }; } async function uploadAsset(releaseId, filePath, token) { const base = process.env.GITEA_BASE_URL || 'https://git.24-music.de'; const name = basename(filePath); const size = statSync(filePath).size; const url = `${base}/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`; console.log(` Uploading ${name} (${(size / 1024 / 1024).toFixed(1)} MB)...`); if (dryRun) { console.log(' [dry-run] skipped'); return; } const stream = createReadStream(filePath); const res = await fetch(url, { method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(size) }, body: stream, duplex: 'half' }); if (!res.ok) { const text = await res.text(); throw new Error(`Asset upload failed (${res.status}): ${text.slice(0, 200)}`); } console.log(` Uploaded: ${name}`); } // --- Main --- async function main(args = process.argv.slice(2)) { const plan = createReleasePlan(parseReleaseArgs(args)); const { version, tag } = plan; dryRun = plan.dryRun; console.log(`\nReleasing ${plan.releaseTitle} via ${tag}${dryRun ? ' [DRY RUN]' : ''}\n`); // 1. Resolve remote const remote = resolveGiteaRemote(); if (!remote) { console.error('No Gitea remote found. Add one: git remote add gitea https://git.24-music.de/Administrator/Multi-Hoster-Upload.git'); process.exit(1); } console.log(`Remote: ${remote.name} -> ${remote.url}`); // 2. Check clean working tree const status = run('git status --porcelain', { allowDry: true }); const trackedChanges = status.split('\n').filter(l => l.trim() && !l.startsWith('??')).join('\n'); if (trackedChanges) { console.error('Working tree has uncommitted tracked changes. Commit or stash first.'); process.exit(1); } // 3. Check if tag already exists (recovery mode) let recoveryMode = false; try { run(`git rev-parse ${tag}`, { allowDry: true }); console.log(`Tag ${tag} already exists - recovery mode (skip version bump/git)`); recoveryMode = true; } catch {} if (!recoveryMode) { // 4. Update package.json version run(`npm version ${version} --no-git-tag-version --allow-same-version`); console.log(`Updated package.json and package-lock.json -> ${version}`); // 5. Build console.log('\nBuilding...'); run('npm run release:win', { stdio: 'inherit' }); // 6. Git commit + tag + push const versionStatus = run('git status --porcelain -- package.json package-lock.json', { allowDry: true }); if (versionStatus) { run('git add package.json package-lock.json'); run(`git commit -m "release: ${tag}"`); } run(`git tag ${tag}`); run(`git push ${remote.name} HEAD`); run(`git push ${remote.name} ${tag}`); } // 6b. Regenerate latest.yml to ensure correct SHA-512 { const setupPath = resolve(RELEASE_DIR, plan.setupName); if (existsSync(setupPath)) { const sha = await new Promise((res, rej) => { const h = createHash('sha512'); const s = createReadStream(setupPath); s.on('data', d => h.update(d)); s.on('end', () => res(h.digest('base64'))); s.on('error', rej); }); const size = statSync(setupPath).size; const yml = renderLatestYml(plan, sha, size); writeFileSync(resolve(RELEASE_DIR, 'latest.yml'), yml, 'utf-8'); console.log('Regenerated latest.yml with correct SHA-512'); } } // 7. Verify artifacts const expectedArtifacts = plan.expectedArtifacts; for (const name of expectedArtifacts) { const p = resolve(RELEASE_DIR, name); if (!existsSync(p)) { console.error(`Missing artifact: ${p}`); process.exit(1); } } // Also check for blockmap const blockmapName = plan.blockmapName; const hasBlockmap = existsSync(resolve(RELEASE_DIR, blockmapName)); console.log('\nArtifacts verified.'); // 8. Get token const token = resolveToken(); if (!token) { console.error('No Gitea token. Set GITEA_TOKEN env var or configure git credential helper.'); process.exit(1); } // 9. Create release let releaseId; const { status: createStatus, data: createData } = await giteaApi( 'POST', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases`, token, { tag_name: tag, name: plan.releaseTitle, body: plan.releaseBody } ); if (createStatus === 409 || createStatus === 422) { // Release already exists, find it const { data: releases } = await giteaApi('GET', `/api/v1/repos/Administrator/${PRODUCT_NAME}/releases/tags/${tag}`, token); releaseId = resolveExistingReleaseId(plan, releases); console.log(`Release already exists (id: ${releaseId})`); } else { releaseId = createData.id; console.log(`Created release (id: ${releaseId})`); } // 10. Upload assets for (const name of expectedArtifacts) { await uploadAsset(releaseId, resolve(RELEASE_DIR, name), token); } if (hasBlockmap) { await uploadAsset(releaseId, resolve(RELEASE_DIR, blockmapName), token); } console.log(`\nDone! Release: ${process.env.GITEA_BASE_URL || 'https://git.24-music.de'}/Administrator/${PRODUCT_NAME}/releases/tag/${tag}\n`); } const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; if (entryPoint === import.meta.url) { main().catch(err => { console.error('\nRelease failed:', err.message); process.exit(1); }); }