ci: publish releases to Gitea instead of GitHub
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>
This commit is contained in:
Rene Kievits
2026-09-05 22:41:03 +02:00
co-authored by Claude Opus 5
parent b137bf9eda
commit bf2daac146
4 changed files with 208 additions and 34 deletions
+26 -9
View File
@@ -7,12 +7,16 @@
* can be added under Homebrew Channel ->
* Settings -> Add repository
*
* Both are uploaded as release assets, so the stable repository URL is
* https://github.com/<owner>/<repo>/releases/latest/download/apps.json
* and the manifest/ipk URLs inside point at the exact tagged release.
* Both are uploaded as release assets, so the repository URL stays stable
* across releases, while the manifest/ipk URLs inside point at the exact
* tagged release.
*
* Usage: node tools/gen-manifest.js [--repo owner/repo] [--tag v1.0.0]
* On GitHub Actions both are picked up from the environment.
* Works against GitHub and Gitea/Forgejo; they spell the "latest release
* asset" URL differently, which is the only thing --server changes.
*
* Usage: node tools/gen-manifest.js [--server https://host] [--repo owner/repo]
* [--tag v1.0.0]
* On GitHub/Gitea Actions all three are picked up from the environment.
*/
'use strict';
@@ -34,6 +38,15 @@ const tag = arg(
process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : `v${pkg.version}`,
);
// PUBLIC_SERVER_URL wins over GITHUB_SERVER_URL because a Gitea runner is
// handed the instance's internal URL (a LAN address), which is useless in a
// manifest the TV has to fetch.
const server = arg(
'--server',
process.env.PUBLIC_SERVER_URL || process.env.GITHUB_SERVER_URL || 'https://github.com',
).replace(/\/+$/, '');
const isGitHub = /(^|\.)github\.com$/i.test(new URL(server).hostname);
const ipkName = `${pkg.name}_${pkg.version}_all.ipk`;
const ipkPath = path.join(root, 'dist', ipkName);
if (!fs.existsSync(ipkPath)) {
@@ -42,8 +55,12 @@ if (!fs.existsSync(ipkPath)) {
}
const ipk = fs.readFileSync(ipkPath);
const releaseBase = repository
? `https://github.com/${repository}/releases/download/${tag}`
const releaseBase = repository ? `${server}/${repository}/releases/download/${tag}` : null;
// GitHub: /releases/latest/download/<file>, Gitea: /releases/download/latest/<file>
const latestBase = repository
? isGitHub
? `${server}/${repository}/releases/latest/download`
: `${server}/${repository}/releases/download/latest`
: null;
if (!releaseBase) {
@@ -65,7 +82,7 @@ const manifest = {
title: pkg.title,
appDescription: pkg.description,
iconUri,
sourceUrl: repository ? `https://github.com/${repository}` : undefined,
sourceUrl: repository ? `${server}/${repository}` : undefined,
rootRequired: true,
ipkUrl: releaseBase ? `${releaseBase}/${ipkName}` : ipkName,
ipkHash: { sha256: crypto.createHash('sha256').update(ipk).digest('hex') },
@@ -99,6 +116,6 @@ console.log(`repository: dist/apps.json`);
if (repository) {
console.log(
`\nAdd this in Homebrew Channel -> Settings -> Add repository:\n` +
` https://github.com/${repository}/releases/latest/download/apps.json`,
` ${latestBase}/apps.json`,
);
}
+137
View File
@@ -0,0 +1,137 @@
#!/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));