Files
Toju/tools/gitea-release.js
2026-03-10 23:56:53 +01:00

655 lines
17 KiB
JavaScript

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;
});