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
+28 -20
View File
@@ -7,6 +7,11 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
env:
# Public URL of this Gitea instance. The runner is handed the instance's
# internal address (a LAN IP), which is no good in a manifest the TV fetches.
PUBLIC_SERVER_URL: https://git.crylia.de
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -30,28 +35,31 @@ jobs:
- name: Generate manifest and repository index - name: Generate manifest and repository index
run: npm run manifest run: npm run manifest
- name: Upload build artifacts - name: Show what was built
uses: actions/upload-artifact@v4 run: ls -la dist
with:
name: lg-update-blocker
path: dist/*
# Tag a commit with v<version> (matching package.json) to publish a # Tag a commit with v<version> (matching package.json) to publish a
# release. The repository URL for the Homebrew Channel stays stable: # release. The repository URL for the Homebrew Channel stays stable:
# https://github.com/<owner>/<repo>/releases/latest/download/apps.json # <server>/<owner>/<repo>/releases/download/latest/apps.json
- name: Write release notes
if: startsWith(github.ref, 'refs/tags/v')
run: |
cat > "${RUNNER_TEMP:-/tmp}/release-notes.md" <<EOF
Install through the Homebrew Channel by adding this repository once
(Homebrew Channel → Settings → Add repository):
\`\`\`
${PUBLIC_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/latest/apps.json
\`\`\`
That URL always resolves to the newest release, so future versions
show up as updates automatically.
Or install the \`.ipk\` below manually with \`ares-install <file>.ipk\`.
EOF
- name: Release - name: Release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2 env:
with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
files: dist/* run: node tools/release-gitea.js --notes-from "${RUNNER_TEMP:-/tmp}/release-notes.md"
generate_release_notes: true
body: |
Install through the Homebrew Channel by adding this repository once
(Homebrew Channel → Settings → Add repository):
```
https://github.com/${{ github.repository }}/releases/latest/download/apps.json
```
Or install the `.ipk` below manually with
`ares-install <file>.ipk`.
+17 -5
View File
@@ -54,12 +54,13 @@ settings from the backup it made.
Add the repository once — *Homebrew Channel → Settings → Add repository*: Add the repository once — *Homebrew Channel → Settings → Add repository*:
``` ```text
https://github.com/<owner>/<repo>/releases/latest/download/apps.json https://git.crylia.de/Crylia/lg_update_blocker/releases/download/latest/apps.json
``` ```
The app then shows up in the Homebrew Channel list, and every new release you The app then shows up in the Homebrew Channel list, and every new release that
tag is picked up automatically (that URL always points at the newest release). is tagged is picked up automatically — Gitea resolves `.../download/latest/...`
to the newest release.
### Manually ### Manually
@@ -125,7 +126,8 @@ npm run deploy # ares-install the resulting ipk
## Releasing ## Releasing
`.github/workflows/release.yml` builds on every push. Tag a commit `v<version>` [.github/workflows/release.yml](.github/workflows/release.yml) builds on every
push (Gitea Actions reads `.github/workflows/` too). Tag a commit `v<version>`
(matching `version` in `package.json`) and the workflow additionally publishes a (matching `version` in `package.json`) and the workflow additionally publishes a
release containing: release containing:
@@ -135,6 +137,16 @@ release containing:
* `apps.json` — the one-package repository index used by the repository URL above * `apps.json` — the one-package repository index used by the repository URL above
* `description.html` — the long description shown in the Homebrew Channel * `description.html` — the long description shown in the Homebrew Channel
Uploading is done by [tools/release-gitea.js](tools/release-gitea.js) against
the Gitea API (`softprops/action-gh-release` only speaks to github.com). It is
idempotent: re-running a tag reuses the release and replaces the attachments.
The URLs inside the manifest come from `PUBLIC_SERVER_URL` in the workflow, not
from `GITHUB_SERVER_URL` — the runner is handed the instance's internal LAN
address, which the TV cannot reach. Change that one variable if the instance
moves. Generating for github.com instead works too: `--server` (or
`PUBLIC_SERVER_URL`) switches the "latest release asset" URL to GitHub's shape.
The app id lives in exactly one place: the `name` field of `package.json`. The app id lives in exactly one place: the `name` field of `package.json`.
Change it there and everything (appinfo, service name, Luna URIs, manifest) Change it there and everything (appinfo, service name, Luna URIs, manifest)
follows. follows.
+26 -9
View File
@@ -7,12 +7,16 @@
* can be added under Homebrew Channel -> * can be added under Homebrew Channel ->
* Settings -> Add repository * Settings -> Add repository
* *
* Both are uploaded as release assets, so the stable repository URL is * Both are uploaded as release assets, so the repository URL stays stable
* https://github.com/<owner>/<repo>/releases/latest/download/apps.json * across releases, while the manifest/ipk URLs inside point at the exact
* and the manifest/ipk URLs inside point at the exact tagged release. * tagged release.
* *
* Usage: node tools/gen-manifest.js [--repo owner/repo] [--tag v1.0.0] * Works against GitHub and Gitea/Forgejo; they spell the "latest release
* On GitHub Actions both are picked up from the environment. * 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'; 'use strict';
@@ -34,6 +38,15 @@ const tag = arg(
process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : `v${pkg.version}`, 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 ipkName = `${pkg.name}_${pkg.version}_all.ipk`;
const ipkPath = path.join(root, 'dist', ipkName); const ipkPath = path.join(root, 'dist', ipkName);
if (!fs.existsSync(ipkPath)) { if (!fs.existsSync(ipkPath)) {
@@ -42,8 +55,12 @@ if (!fs.existsSync(ipkPath)) {
} }
const ipk = fs.readFileSync(ipkPath); const ipk = fs.readFileSync(ipkPath);
const releaseBase = repository const releaseBase = repository ? `${server}/${repository}/releases/download/${tag}` : null;
? `https://github.com/${repository}/releases/download/${tag}` // 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; : null;
if (!releaseBase) { if (!releaseBase) {
@@ -65,7 +82,7 @@ const manifest = {
title: pkg.title, title: pkg.title,
appDescription: pkg.description, appDescription: pkg.description,
iconUri, iconUri,
sourceUrl: repository ? `https://github.com/${repository}` : undefined, sourceUrl: repository ? `${server}/${repository}` : undefined,
rootRequired: true, rootRequired: true,
ipkUrl: releaseBase ? `${releaseBase}/${ipkName}` : ipkName, ipkUrl: releaseBase ? `${releaseBase}/${ipkName}` : ipkName,
ipkHash: { sha256: crypto.createHash('sha256').update(ipk).digest('hex') }, ipkHash: { sha256: crypto.createHash('sha256').update(ipk).digest('hex') },
@@ -99,6 +116,6 @@ console.log(`repository: dist/apps.json`);
if (repository) { if (repository) {
console.log( console.log(
`\nAdd this in Homebrew Channel -> Settings -> Add repository:\n` + `\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));