Build / build (push) Successful in 48s
The workflow was written for github.com: actions/upload-artifact failed on the Gitea runner, softprops/action-gh-release only talks to the GitHub API, and gen-manifest.js hardcoded github.com URLs. - drop the artifact upload (release assets are the deliverable) - upload release assets with tools/release-gitea.js, a dependency-free Gitea API client; re-running a tag replaces the attachments - gen-manifest.js takes --server/PUBLIC_SERVER_URL and knows that Gitea spells the latest-asset URL /releases/download/latest/<file> - PUBLIC_SERVER_URL is set explicitly because the runner only sees the instance's internal LAN address Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
4.7 KiB
JavaScript
138 lines
4.7 KiB
JavaScript
#!/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));
|