From bf2daac146f54adbde8b7dac615bddbab4334289 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Sat, 5 Sep 2026 22:40:34 +0200 Subject: [PATCH] ci: publish releases to Gitea instead of GitHub 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/ - PUBLIC_SERVER_URL is set explicitly because the runner only sees the instance's internal LAN address Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 48 +++++++----- README.md | 22 ++++-- tools/gen-manifest.js | 35 ++++++--- tools/release-gitea.js | 137 ++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 tools/release-gitea.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f082ed..c37b8ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: pull_request: 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: build: runs-on: ubuntu-latest @@ -30,28 +35,31 @@ jobs: - name: Generate manifest and repository index run: npm run manifest - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: lg-update-blocker - path: dist/* + - name: Show what was built + run: ls -la dist # Tag a commit with v (matching package.json) to publish a # release. The repository URL for the Homebrew Channel stays stable: - # https://github.com///releases/latest/download/apps.json + # ///releases/download/latest/apps.json + - name: Write release notes + if: startsWith(github.ref, 'refs/tags/v') + run: | + cat > "${RUNNER_TEMP:-/tmp}/release-notes.md" <.ipk\`. + EOF + - name: Release if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v2 - with: - files: dist/* - 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 .ipk`. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node tools/release-gitea.js --notes-from "${RUNNER_TEMP:-/tmp}/release-notes.md" diff --git a/README.md b/README.md index b35585c..fd1dc2d 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,13 @@ settings from the backup it made. Add the repository once — *Homebrew Channel → Settings → Add repository*: -``` -https://github.com///releases/latest/download/apps.json +```text +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 -tag is picked up automatically (that URL always points at the newest release). +The app then shows up in the Homebrew Channel list, and every new release that +is tagged is picked up automatically — Gitea resolves `.../download/latest/...` +to the newest release. ### Manually @@ -125,7 +126,8 @@ npm run deploy # ares-install the resulting ipk ## Releasing -`.github/workflows/release.yml` builds on every push. Tag a commit `v` +[.github/workflows/release.yml](.github/workflows/release.yml) builds on every +push (Gitea Actions reads `.github/workflows/` too). Tag a commit `v` (matching `version` in `package.json`) and the workflow additionally publishes a release containing: @@ -135,6 +137,16 @@ release containing: * `apps.json` — the one-package repository index used by the repository URL above * `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`. Change it there and everything (appinfo, service name, Luna URIs, manifest) follows. diff --git a/tools/gen-manifest.js b/tools/gen-manifest.js index 7b4e9dd..3891a7b 100644 --- a/tools/gen-manifest.js +++ b/tools/gen-manifest.js @@ -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///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/, Gitea: /releases/download/latest/ +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`, ); } diff --git a/tools/release-gitea.js b/tools/release-gitea.js new file mode 100644 index 0000000..ce904f2 --- /dev/null +++ b/tools/release-gitea.js @@ -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));