#!/usr/bin/env node import path from 'node:path'; import { readFile, access } from 'node:fs/promises'; import { createHash } from 'node:crypto'; const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; function usage() { return `Usage: node scripts/publish-release.mjs [options] Uploads prepared release artifacts to a Gitea release. Options: --api-base URL Gitea API base (default: https://git.add-ideas.de/api/v1) --owner OWNER Gitea repository owner (default: lotobo) --repo REPO Gitea repository (default: toolbox-portal) --tag TAG Release tag, e.g. v0.20.3 (default: from --lock-file) --target HASH Target commit SHA for release creation --name NAME Release name --body BODY Optional release body --archive PATH Release ZIP path --checksum PATH SHA256 file path --lock-file PATH Release lock file (default: release/toolbox.lock.json) --token TOKEN Gitea API token; defaults to GITEA_TOKEN env --dry-run Print actions without contacting Gitea --help Show this help `; } function parseArguments(argv) { if (argv.includes('--help')) { console.log(usage()); process.exit(0); } const args = new Map(); for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (!arg.startsWith('--')) throw new Error(`Unknown argument ${arg}`); if (arg === '--dry-run') { args.set(arg, true); continue; } const value = argv[index + 1]; if (value === undefined) throw new Error(`Missing value for ${arg}`); args.set(arg, value); index += 1; } return { apiBase: args.get('--api-base') ?? 'https://git.add-ideas.de/api/v1', owner: args.get('--owner') ?? 'lotobo', repo: args.get('--repo') ?? 'toolbox-portal', tag: args.get('--tag'), target: args.get('--target'), name: args.get('--name') ?? 'add-ideas Toolbox release', body: args.get('--body'), archive: args.get('--archive'), checksum: args.get('--checksum'), lockFile: args.get('--lock-file') ?? 'release/toolbox.lock.json', token: args.get('--token') ?? process.env.GITEA_TOKEN, dryRun: args.get('--dry-run') === true, }; } async function readTokenFromEnvFile() { const xdgConfig = process.env.XDG_CONFIG_HOME; const homeConfig = process.env.HOME ? `${process.env.HOME}/.config/gitea/gitea.env` : null; const candidates = []; if (xdgConfig) candidates.push(`${xdgConfig}/gitea/gitea.env`); if (homeConfig) candidates.push(homeConfig); for (const candidate of candidates) { try { await access(candidate); } catch { continue; } const fileContent = await readFile(candidate, 'utf8'); const tokenLine = fileContent .split('\n') .map((line) => line.trim()) .find((line) => line.startsWith('GITEA_TOKEN=')); if (!tokenLine) continue; const token = tokenLine.split('=', 2)[1]?.trim(); if (token) return token; } return null; } async function giteaRequest(url, options, { allowNotFound = false } = {}) { const response = await fetch(url, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), }, }); if (allowNotFound && response.status === 404) { return { notFound: true, status: 404 }; } const text = await response.text(); const payload = text ? (() => { try { return JSON.parse(text); } catch { return { raw: text }; } })() : null; if (!response.ok) { const detail = payload && typeof payload === 'object' && payload.error ? payload.error : payload?.raw ?? text ?? ''; throw new Error(`Gitea API request failed (${response.status} ${response.statusText}): ${detail}`); } return payload; } function sha256Digest(content) { return createHash('sha256').update(content).digest('hex'); } function parseChecksumFile(raw) { const [first] = String(raw).trim().split(/\s+/); return first?.toLowerCase() ?? ''; } function parseReleaseVersionFromLock(lockJson) { return lockJson.releaseVersion; } async function run() { const options = parseArguments(process.argv.slice(2)); const lock = await readFile(options.lockFile, 'utf8'); const lockJson = JSON.parse(lock); if (!SEMVER.test(lockJson.releaseVersion)) { throw new Error(`Invalid releaseVersion in lock: ${lockJson.releaseVersion}`); } const releaseVersion = parseReleaseVersionFromLock(lockJson); const releaseTag = options.tag ?? `v${releaseVersion}`; const dryRun = options.dryRun === true; let token = options.token; if (!token && !dryRun) token = await readTokenFromEnvFile(); if (!token && !dryRun) { throw new Error('GITEA_TOKEN is required. Set env or provide --token.'); } if (!token && dryRun) token = 'dry-run-token'; if (!options.archive) { throw new Error('Missing --archive'); } if (!options.checksum) { throw new Error('Missing --checksum'); } const archive = path.resolve(options.archive); const checksumFile = path.resolve(options.checksum); const [archiveData, checksumRaw] = await Promise.all([ readFile(archive), readFile(checksumFile, 'utf8'), ]); const expectedChecksum = parseChecksumFile(checksumRaw); const computedChecksum = sha256Digest(archiveData); if (!expectedChecksum) { throw new Error(`Checksum file is empty: ${checksumFile}`); } if (!/^[a-f0-9]{64}$/i.test(expectedChecksum)) { throw new Error(`Invalid checksum format in ${checksumFile}`); } if (expectedChecksum.toLowerCase() !== computedChecksum.toLowerCase()) { throw new Error( `Checksum mismatch for ${options.archive}. computed=${computedChecksum} file=${expectedChecksum}` ); } if (dryRun) { console.log(`DRY-RUN: would ensure or create release ${releaseTag} on ${options.owner}/${options.repo}.`); console.log(`DRY-RUN: would upload assets ${path.basename(archive)} and ${path.basename(checksumFile)}.`); return; } const apiBase = options.apiBase.replace(/\/+$/, ''); const releasePrefix = `${apiBase}/repos/${options.owner}/${options.repo}`; const auth = `token ${token}`; const headers = { Authorization: auth }; const tagUrl = `${releasePrefix}/releases/tags/${encodeURIComponent(releaseTag)}`; const tagLookup = await giteaRequest( tagUrl, { method: 'GET', headers }, { allowNotFound: true } ); let release; if (tagLookup?.notFound) { const body = { tag_name: releaseTag, target_commitish: options.target || 'main', name: options.name, body: options.body || `Automated release of add-ideas toolbox ${releaseTag} from lock release ${releaseVersion}.`, draft: false, prerelease: false, }; if (dryRun) { console.log(`DRY-RUN: would create release ${releaseTag} on ${options.owner}/${options.repo}`); release = { id: '', assets: [] }; } else { release = await giteaRequest(`${releasePrefix}/releases`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); console.log(`Created release ${releaseTag} (${release.id})`); } } else { release = tagLookup; if (dryRun) { console.log(`DRY-RUN: would reuse existing release ${releaseTag} (${release?.id ?? 'unknown'})`); } else { console.log(`Using existing release ${releaseTag} (${release.id})`); } } if (dryRun) { console.log('DRY-RUN: would upload assets:', options.archive, options.checksum); return; } const existingAssets = release.assets ?? []; const candidateAssets = [ { path: archive, name: path.basename(archive) }, { path: checksumFile, name: path.basename(checksumFile) }, ]; for (const asset of candidateAssets) { const duplicate = existingAssets.find((entry) => entry.name === asset.name); if (duplicate) { console.log(`Deleting existing asset ${asset.name} from release ${releaseTag}`); await giteaRequest( `${releasePrefix}/releases/assets/${duplicate.id}`, { method: 'DELETE', headers }, { allowNotFound: true } ); } const payload = await readFile(asset.path); const form = new FormData(); form.set('attachment', new Blob([payload]), asset.name); console.log(`Uploading ${asset.name}`); await giteaRequest( `${releasePrefix}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, { method: 'POST', headers, body: form, } ); } console.log(`Release ${releaseTag} published on ${options.owner}/${options.repo}`); } run().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });