Files
punktfunk/packaging/winget/server/build-data.mjs
T
enricobuehlerandClaude Opus 5 1839d7566b
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
feat(packaging/winget): install the Windows host with winget, from our own source
`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>
2026-07-26 19:09:13 +02:00

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(", ")})`);