#!/usr/bin/env node /** * Publishes dist/* as attachments on a Gitea/Forgejo release. * * softprops/action-gh-release only talks to the GitHub API, so on a Gitea * runner we do it ourselves. Node 20 has fetch/FormData/Blob built in, so this * needs no dependencies. * * Re-running for the same tag is safe: the release is reused and same-named * attachments are replaced. * * Env (all set automatically by Gitea Actions): * GITHUB_TOKEN / GITEA_TOKEN - token with write access to the repository * GITHUB_API_URL - e.g. https://git.example.com/api/v1 * GITHUB_SERVER_URL - fallback for GITHUB_API_URL * GITHUB_REPOSITORY - owner/repo * GITHUB_REF_NAME - the tag, when GITHUB_REF_TYPE is "tag" * * Usage: node tools/release-gitea.js [--api URL] [--repo owner/repo] * [--tag v1.0.0] [--notes-from file] */ 'use strict'; const fs = require('fs'); const path = require('path'); const root = path.resolve(__dirname, '..'); function arg(name, fallback) { const index = process.argv.indexOf(name); return index !== -1 && process.argv[index + 1] ? process.argv[index + 1] : fallback; } function fail(message) { console.error(message); process.exit(1); } const token = process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN || ''; const repository = arg('--repo', process.env.GITHUB_REPOSITORY || ''); const tag = arg( '--tag', process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : '', ); const api = arg( '--api', process.env.GITHUB_API_URL || (process.env.GITHUB_SERVER_URL ? `${process.env.GITHUB_SERVER_URL}/api/v1` : ''), ).replace(/\/+$/, ''); if (!token) fail('No GITHUB_TOKEN/GITEA_TOKEN in the environment.'); if (!repository) fail('No repository - pass --repo owner/repo or set GITHUB_REPOSITORY.'); if (!tag) fail('No tag - pass --tag v1.0.0 or run this on a tag push.'); if (!api) fail('No API URL - pass --api https://host/api/v1 or set GITHUB_API_URL.'); const [owner, repo] = repository.split('/'); const base = `${api}/repos/${owner}/${repo}`; const notesPath = arg('--notes-from', ''); const distDir = path.join(root, 'dist'); async function call(method, url, { body, headers } = {}) { const response = await fetch(url, { method, headers: { Authorization: `token ${token}`, ...(headers || {}) }, body, }); const text = await response.text(); if (!response.ok) { throw new Error(`${method} ${url} -> ${response.status} ${response.statusText}\n${text}`); } return text ? JSON.parse(text) : {}; } async function findRelease() { const response = await fetch(`${base}/releases/tags/${encodeURIComponent(tag)}`, { headers: { Authorization: `token ${token}` }, }); if (response.status === 404) return null; if (!response.ok) { throw new Error(`lookup of ${tag} -> ${response.status} ${response.statusText}`); } return response.json(); } async function main() { const files = fs.existsSync(distDir) ? fs.readdirSync(distDir).filter((name) => fs.statSync(path.join(distDir, name)).isFile()) : []; if (!files.length) fail('dist/ is empty - run "npm run dist" first.'); const notes = notesPath && fs.existsSync(notesPath) ? fs.readFileSync(notesPath, 'utf8') : ''; let release = await findRelease(); if (release) { console.log(`reusing release ${tag} (#${release.id})`); if (notes) { release = await call('PATCH', `${base}/releases/${release.id}`, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: notes }), }); } } else { release = await call('POST', `${base}/releases`, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag_name: tag, name: tag, body: notes, draft: false, prerelease: false, }), }); console.log(`created release ${tag} (#${release.id})`); } const existing = new Map((release.assets || []).map((asset) => [asset.name, asset.id])); for (const name of files) { if (existing.has(name)) { await call('DELETE', `${base}/releases/${release.id}/assets/${existing.get(name)}`); console.log(`replaced ${name}`); } const data = fs.readFileSync(path.join(distDir, name)); const form = new FormData(); form.append('attachment', new Blob([data], { type: 'application/octet-stream' }), name); await call( 'POST', `${base}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { body: form }, ); console.log(`uploaded ${name} (${(data.length / 1024).toFixed(1)} kB)`); } console.log(`\n${release.html_url || `${base}/releases/${release.id}`}`); } main().catch((error) => fail(error.message));