docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m56s
apple / screenshots (push) Canceled after 11m30s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 6m2s
ci / web (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m29s
ci / rust-arm64 (push) Successful in 9m56s
android / android (push) Successful in 12m33s
ci / bench (push) Successful in 7m17s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
arch / build-publish (push) Successful in 18m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m2s
ci / rust (push) Failing after 19m38s
deb / build-publish-client-arm64 (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 9m43s
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.
Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.
Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.
Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.
It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.
NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.
28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
7.0 KiB
JavaScript
156 lines
7.0 KiB
JavaScript
// Build src/data.json — the payload the Worker serves — from published winget manifests.
|
|
//
|
|
// Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest
|
|
// trio to each stable tag, so walking releases yields every version the source should offer. That
|
|
// matters because winget resolves `--version` and `upgrade` against the version LIST; a source that
|
|
// only knows the newest release can neither pin an older one nor show an upgrade path from it.
|
|
//
|
|
// Deriving from releases also means this script holds no state. Re-running it reproduces the same
|
|
// data.json, so a lost deployment is one command away and there is no accumulated file to drift.
|
|
//
|
|
// node build-data.mjs # from Gitea releases (what CI runs)
|
|
// node build-data.mjs --local ../ # from a local manifest dir (dev, single version)
|
|
// node build-data.mjs --out src/data.json
|
|
//
|
|
// The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser.
|
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parse as parseYaml } from "yaml";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1";
|
|
const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk";
|
|
const PACKAGE_ID = "unom.PunktfunkHost";
|
|
const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk";
|
|
// The API contract this source implements. Advertised via /information so a future client can
|
|
// negotiate; bump only alongside a real change to the response shapes.
|
|
const SERVER_SUPPORTED_VERSIONS = ["1.1.0"];
|
|
|
|
const args = process.argv.slice(2);
|
|
const argVal = (flag) => {
|
|
const i = args.indexOf(flag);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
};
|
|
const outPath = resolve(HERE, argVal("--out") ?? "src/data.json");
|
|
const localDir = argVal("--local");
|
|
|
|
/** One version's three manifests -> the shape the Worker serves. */
|
|
function toVersionEntry(installer, locale, productCodeFallback) {
|
|
// The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits
|
|
// them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong
|
|
// in the merged object.
|
|
const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer;
|
|
const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale;
|
|
|
|
// Installer-level fields are inherited by each entry in Installers unless overridden there. The
|
|
// REST shape has no inheritance, so fold the parent down into every installer.
|
|
const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst }));
|
|
|
|
return {
|
|
PackageVersion,
|
|
DefaultLocale: { PackageLocale, ...localeRest },
|
|
Installers: installers,
|
|
ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean),
|
|
};
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function fetchText(url) {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
|
return res.text();
|
|
}
|
|
|
|
async function versionsFromReleases() {
|
|
const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`);
|
|
const out = [];
|
|
for (const rel of releases) {
|
|
if (rel.draft) continue;
|
|
const assets = rel.assets ?? [];
|
|
const find = (suffix) =>
|
|
assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url;
|
|
const installerUrl = find(".installer.yaml");
|
|
const localeUrl = find(".locale.en-US.yaml");
|
|
// Releases from before winget support shipped have no manifests — skip, don't fail.
|
|
if (!installerUrl || !localeUrl) continue;
|
|
const [installer, locale] = await Promise.all([
|
|
fetchText(installerUrl).then(parseYaml),
|
|
fetchText(localeUrl).then(parseYaml),
|
|
]);
|
|
out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease });
|
|
console.log(` + ${rel.tag_name} (${installer.PackageVersion})`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function versionsFromLocal(dir) {
|
|
const d = resolve(HERE, dir);
|
|
const names = readdirSync(d);
|
|
const read = (suffix) => {
|
|
const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`);
|
|
if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`);
|
|
return parseYaml(readFileSync(join(d, n), "utf8"));
|
|
};
|
|
const installer = read(".installer.yaml");
|
|
const locale = read(".locale.en-US.yaml");
|
|
console.log(` + local (${installer.PackageVersion})`);
|
|
return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }];
|
|
}
|
|
|
|
/** Newest first, so winget's "latest" is the head of the list. */
|
|
function compareVersionsDesc(a, b) {
|
|
const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
|
const [pa, pb] = [parts(a), parts(b)];
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const x = pa[i] ?? 0;
|
|
const y = pb[i] ?? 0;
|
|
if (x === y) continue;
|
|
// A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1).
|
|
if (typeof x === typeof y) return x > y ? -1 : 1;
|
|
return typeof x === "number" ? -1 : 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`);
|
|
const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases();
|
|
if (collected.length === 0) {
|
|
throw new Error("no winget manifests found - refusing to write an empty source");
|
|
}
|
|
|
|
const versions = collected.map((c) => c.entry).sort(compareVersionsDesc);
|
|
// Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a
|
|
// retagged description takes effect without rewriting history.
|
|
const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale;
|
|
|
|
const data = {
|
|
sourceIdentifier: SOURCE_IDENTIFIER,
|
|
serverSupportedVersions: SERVER_SUPPORTED_VERSIONS,
|
|
packages: [
|
|
{
|
|
PackageIdentifier: PACKAGE_ID,
|
|
PackageName: newestLocale.PackageName,
|
|
Publisher: newestLocale.Publisher,
|
|
Moniker: newestLocale.Moniker ?? null,
|
|
Tags: newestLocale.Tags ?? [],
|
|
// Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships.
|
|
ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))],
|
|
Versions: versions.map((v) => ({
|
|
PackageVersion: v.PackageVersion,
|
|
ProductCodes: v.ProductCodes,
|
|
})),
|
|
Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions },
|
|
},
|
|
],
|
|
};
|
|
|
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`);
|