Add runner ci (test)
This commit is contained in:
654
tools/gitea-release.js
Normal file
654
tools/gitea-release.js
Normal file
@@ -0,0 +1,654 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user