655 lines
17 KiB
JavaScript
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;
|
|
});
|