268 lines
9.0 KiB
JavaScript
268 lines
9.0 KiB
JavaScript
#!/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 <version> --transport-tag <vX.Y.Z> [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 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 = releases.id;
|
|
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);
|
|
});
|
|
}
|