const fs = require('fs'); const path = require('path'); const http = require('http'); const https = require('https'); function parseArgs(argv) { const args = { _: [] }; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith('--')) { args._.push(token); continue; } const key = token.slice(2); const nextToken = argv[index + 1]; if (!nextToken || nextToken.startsWith('--')) { args[key] = true; continue; } args[key] = nextToken; index += 1; } return args; } function requireArg(args, key) { const value = args[key] ?? process.env[key.toUpperCase().replace(/-/g, '_')]; if (typeof value !== 'string' || value.trim().length === 0) { throw new Error(`Missing required argument: --${key}`); } return value.trim(); } function resolveToken(args) { const token = args.token || process.env.GITEA_RELEASE_TOKEN || process.env.GITHUB_TOKEN; if (typeof token !== 'string' || token.trim().length === 0) { throw new Error('A Gitea API token is required. Set GITEA_RELEASE_TOKEN or pass --token.'); } return token.trim(); } function normalizeServerUrl(rawValue) { const parsedUrl = new URL(rawValue.trim()); if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { throw new Error('The Gitea server URL must use http:// or https://.'); } return parsedUrl.toString().replace(/\/$/, ''); } function parseRepository(rawValue) { const trimmedValue = rawValue.trim(); const [owner, ...repoParts] = trimmedValue.split('/'); if (!owner || repoParts.length === 0) { throw new Error(`Repository must be in OWNER/REPO format. Received: ${rawValue}`); } return { owner, repo: repoParts.join('/') }; } function buildApiUrl(serverUrl, repository, apiPath, query = {}) { const { owner, repo } = parseRepository(repository); const pathname = apiPath .replace('{owner}', encodeURIComponent(owner)) .replace('{repo}', encodeURIComponent(repo)); const url = new URL(`${serverUrl}/api/v1${pathname}`); for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null || value === '') { continue; } url.searchParams.set(key, String(value)); } return url; } function buildAuthHeaders(token) { return { Authorization: `token ${token}`, 'X-Gitea-Token': token }; } function sendRequest({ body, expectedStatuses = [200], headers = {}, maxRedirects = 5, method, token, url }) { return new Promise((resolve, reject) => { const requestUrl = typeof url === 'string' ? new URL(url) : url; const transport = requestUrl.protocol === 'https:' ? https : http; const request = transport.request({ method, hostname: requestUrl.hostname, port: requestUrl.port || undefined, path: `${requestUrl.pathname}${requestUrl.search}`, headers: { Accept: 'application/json', ...buildAuthHeaders(token), ...headers } }, (response) => { const statusCode = response.statusCode || 0; const location = response.headers.location; if (statusCode >= 300 && statusCode < 400 && location && maxRedirects > 0) { response.resume(); sendRequest({ body, expectedStatuses, headers, maxRedirects: maxRedirects - 1, method, token, url: new URL(location, requestUrl) }).then(resolve, reject); return; } const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => { const responseBody = Buffer.concat(chunks); if (!expectedStatuses.includes(statusCode)) { reject(new Error(`${method} ${requestUrl} failed with status ${statusCode}: ${responseBody.toString('utf8')}`)); return; } resolve({ body: responseBody, headers: response.headers, statusCode }); }); }); request.on('error', reject); if (body) { request.end(body); return; } request.end(); }); } async function sendJsonRequest({ expectedStatuses, jsonBody, method, repository, serverUrl, token, urlPath }) { const body = jsonBody ? Buffer.from(JSON.stringify(jsonBody), 'utf8') : undefined; const response = await sendRequest({ body, expectedStatuses, headers: jsonBody ? { 'Content-Length': body.length, 'Content-Type': 'application/json' } : undefined, method, token, url: buildApiUrl(serverUrl, repository, urlPath) }); const responseText = response.body.toString('utf8').trim(); return responseText.length > 0 ? JSON.parse(responseText) : null; } async function sendJsonRequestWithQuery({ expectedStatuses, jsonBody, method, query, repository, serverUrl, token, urlPath }) { const body = jsonBody ? Buffer.from(JSON.stringify(jsonBody), 'utf8') : undefined; const response = await sendRequest({ body, expectedStatuses, headers: jsonBody ? { 'Content-Length': body.length, 'Content-Type': 'application/json' } : undefined, method, token, url: buildApiUrl(serverUrl, repository, urlPath, query) }); const responseText = response.body.toString('utf8').trim(); return responseText.length > 0 ? JSON.parse(responseText) : null; } async function getReleaseByTag({ repository, serverUrl, tag, token }) { try { return await sendJsonRequest({ expectedStatuses: [200], method: 'GET', repository, serverUrl, token, urlPath: `/repos/{owner}/{repo}/releases/tags/${encodeURIComponent(tag)}` }); } catch (error) { if (error instanceof Error && /status 404/.test(error.message)) { return null; } throw error; } } async function getReleaseById({ id, repository, serverUrl, token }) { return sendJsonRequest({ expectedStatuses: [200], method: 'GET', repository, serverUrl, token, urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(id))}` }); } async function createRelease({ body, draft, name, prerelease, repository, serverUrl, tag, target, token }) { return sendJsonRequest({ expectedStatuses: [201], jsonBody: { body, draft, name, prerelease, tag_name: tag, target_commitish: target }, method: 'POST', repository, serverUrl, token, urlPath: '/repos/{owner}/{repo}/releases' }); } async function updateRelease({ body, draft, id, name, prerelease, repository, serverUrl, tag, target, token }) { return sendJsonRequest({ expectedStatuses: [200], jsonBody: { body, draft, name, prerelease, tag_name: tag, target_commitish: target }, method: 'PATCH', repository, serverUrl, token, urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(id))}` }); } async function listReleaseAssets({ releaseId, repository, serverUrl, token }) { return sendJsonRequest({ expectedStatuses: [200], method: 'GET', repository, serverUrl, token, urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets` }); } async function listReleases({ draft, limit, repository, serverUrl, token }) { return sendJsonRequestWithQuery({ expectedStatuses: [200], method: 'GET', query: { draft, limit, page: 1 }, repository, serverUrl, token, urlPath: '/repos/{owner}/{repo}/releases' }); } async function deleteReleaseAsset({ attachmentId, releaseId, repository, serverUrl, token }) { await sendRequest({ expectedStatuses: [204], method: 'DELETE', token, url: buildApiUrl( serverUrl, repository, `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets/${encodeURIComponent(String(attachmentId))}` ) }); } function uploadReleaseAsset({ filePath, releaseId, repository, serverUrl, token }) { return new Promise((resolve, reject) => { const fileName = path.basename(filePath); const boundary = `----MetoYouRelease${Date.now().toString(16)}`; const preamble = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName.replace(/"/g, '')}"\r\nContent-Type: application/octet-stream\r\n\r\n`, 'utf8' ); const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'); const fileSize = fs.statSync(filePath).size; const uploadUrl = buildApiUrl( serverUrl, repository, `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets`, { name: fileName } ); const transport = uploadUrl.protocol === 'https:' ? https : http; const request = transport.request({ method: 'POST', hostname: uploadUrl.hostname, port: uploadUrl.port || undefined, path: `${uploadUrl.pathname}${uploadUrl.search}`, headers: { Accept: 'application/json', ...buildAuthHeaders(token), 'Content-Length': preamble.length + fileSize + closing.length, 'Content-Type': `multipart/form-data; boundary=${boundary}` } }, (response) => { const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => { const statusCode = response.statusCode || 0; const responseBody = Buffer.concat(chunks).toString('utf8'); if (statusCode !== 201) { reject(new Error(`Failed to upload ${fileName}: ${statusCode} ${responseBody}`)); return; } resolve(responseBody.trim().length > 0 ? JSON.parse(responseBody) : null); }); }); request.on('error', reject); request.write(preamble); const stream = fs.createReadStream(filePath); stream.on('error', (error) => { request.destroy(error); reject(error); }); stream.on('end', () => request.end(closing)); stream.pipe(request, { end: false }); }); } async function downloadFile({ filePath, token, url }) { const response = await sendRequest({ expectedStatuses: [200], method: 'GET', token, url }); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, response.body); } function writeGitHubOutputs(entries) { if (!process.env.GITHUB_OUTPUT) { return; } for (const [key, value] of Object.entries(entries)) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`, 'utf8'); } } function toDownloadUrl(serverUrl, repository, tag) { return `${serverUrl}/${repository}/releases/download/${encodeURIComponent(tag)}`; } function collectTopLevelFiles(directoryPath) { if (!fs.existsSync(directoryPath)) { return []; } return fs.readdirSync(directoryPath, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => path.join(directoryPath, entry.name)); } function isDesktopReleaseAsset(filePath) { const fileName = path.basename(filePath); const lowerFileName = fileName.toLowerCase(); return fileName === 'latest.yml' || fileName === 'latest-linux.yml' || fileName === 'latest-mac.yml' || fileName === 'release-manifest.json' || lowerFileName.endsWith('.appimage') || lowerFileName.endsWith('.blockmap') || lowerFileName.endsWith('.deb') || lowerFileName.endsWith('.dmg') || lowerFileName.endsWith('.exe') || lowerFileName.endsWith('.zip'); } function collectBuiltAssets(args) { const distElectronDir = path.resolve(process.cwd(), args['dist-electron'] || 'dist-electron'); const distServerDir = path.resolve(process.cwd(), args['dist-server'] || 'dist-server'); const files = [ ...collectTopLevelFiles(distElectronDir).filter(isDesktopReleaseAsset), ...collectTopLevelFiles(distServerDir) ]; return [...new Set(files)].sort((left, right) => left.localeCompare(right)); } async function ensureDraftReleaseCommand(args) { const token = resolveToken(args); const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); const repository = requireArg(args, 'repository'); const tag = requireArg(args, 'tag'); const target = requireArg(args, 'target'); const name = requireArg(args, 'name'); const body = typeof args.body === 'string' ? args.body : ''; const existingRelease = await getReleaseByTag({ repository, serverUrl, tag, token }); const release = existingRelease ? await updateRelease({ body, draft: Boolean(existingRelease.draft), id: existingRelease.id, name, prerelease: Boolean(existingRelease.prerelease), repository, serverUrl, tag, target, token }) : await createRelease({ body, draft: true, name, prerelease: false, repository, serverUrl, tag, target, token }); const outputs = { release_download_url: toDownloadUrl(serverUrl, repository, release.tag_name), release_html_url: release.html_url, release_id: String(release.id), release_tag: release.tag_name }; if (args['write-output']) { writeGitHubOutputs(outputs); } console.log(JSON.stringify({ ...outputs, release_name: release.name }, null, 2)); } async function downloadLatestManifestCommand(args) { const token = resolveToken(args); const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); const repository = requireArg(args, 'repository'); const outputPath = path.resolve(process.cwd(), requireArg(args, 'output')); const allowMissing = Boolean(args['allow-missing']); const releases = await listReleases({ draft: false, limit: 20, repository, serverUrl, token }); for (const release of Array.isArray(releases) ? releases : []) { if (!release || release.draft) { continue; } const assets = await listReleaseAssets({ releaseId: release.id, repository, serverUrl, token }); const manifestAsset = Array.isArray(assets) ? assets.find((asset) => asset && asset.name === 'release-manifest.json') : null; if (!manifestAsset) { continue; } await downloadFile({ filePath: outputPath, token, url: manifestAsset.browser_download_url }); console.log(`[gitea-release] Downloaded ${manifestAsset.name} from ${release.tag_name}`); return; } if (allowMissing) { console.log('[gitea-release] No published release manifest was found. Continuing without a previous manifest.'); return; } throw new Error('No published release-manifest.json asset was found.'); } async function uploadBuiltAssetsCommand(args) { const token = resolveToken(args); const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); const repository = requireArg(args, 'repository'); const releaseId = requireArg(args, 'release-id'); const files = collectBuiltAssets(args); if (files.length === 0) { throw new Error('No built assets were found to upload.'); } const existingAssets = await listReleaseAssets({ releaseId, repository, serverUrl, token }); const existingAssetsByName = new Map( (Array.isArray(existingAssets) ? existingAssets : []).map((asset) => [asset.name, asset]) ); for (const filePath of files) { const fileName = path.basename(filePath); const existingAsset = existingAssetsByName.get(fileName); if (existingAsset) { await deleteReleaseAsset({ attachmentId: existingAsset.id, releaseId, repository, serverUrl, token }); } await uploadReleaseAsset({ filePath, releaseId, repository, serverUrl, token }); console.log(`[gitea-release] Uploaded ${fileName}`); } } async function publishReleaseCommand(args) { const token = resolveToken(args); const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); const repository = requireArg(args, 'repository'); const tag = requireArg(args, 'tag'); const release = await getReleaseByTag({ repository, serverUrl, tag, token }); if (!release) { throw new Error(`Release ${tag} was not found.`); } const publishedRelease = release.draft ? await updateRelease({ body: release.body || '', draft: false, id: release.id, name: release.name || release.tag_name, prerelease: Boolean(release.prerelease), repository, serverUrl, tag: release.tag_name, target: release.target_commitish, token }) : release; if (args['write-output']) { writeGitHubOutputs({ release_html_url: publishedRelease.html_url, release_id: String(publishedRelease.id), release_tag: publishedRelease.tag_name }); } console.log(JSON.stringify({ release_html_url: publishedRelease.html_url, release_id: publishedRelease.id, release_tag: publishedRelease.tag_name }, null, 2)); } async function main() { const args = parseArgs(process.argv.slice(2)); const command = args._[0]; switch (command) { case 'ensure-draft': await ensureDraftReleaseCommand(args); return; case 'download-latest-manifest': await downloadLatestManifestCommand(args); return; case 'upload-built-assets': await uploadBuiltAssetsCommand(args); return; case 'publish': await publishReleaseCommand(args); return; default: throw new Error(`Unsupported command: ${command || '(none)'}`); } } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(`[gitea-release] ${message}`); process.exitCode = 1; });