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>
141 lines
6.6 KiB
Markdown
141 lines
6.6 KiB
Markdown
# Punktfunk winget REST source
|
|
|
|
A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the
|
|
Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a
|
|
`setup.exe` by hand.
|
|
|
|
```powershell
|
|
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
|
|
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
|
|
|
|
winget install unom.PunktfunkHost # silent, wizard defaults
|
|
winget install unom.PunktfunkHost --interactive # the full wizard
|
|
winget upgrade unom.PunktfunkHost
|
|
```
|
|
|
|
## Why self-hosted rather than the community repo
|
|
|
|
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
|
|
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
|
|
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
|
|
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
|
|
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
|
|
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
|
|
never accept.
|
|
|
|
## What it implements
|
|
|
|
The winget client consumes exactly three endpoints. The reference implementation's other twenty
|
|
(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release
|
|
time has nothing to mutate, so they are not implemented.
|
|
|
|
| Endpoint | Notes |
|
|
| --- | --- |
|
|
| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. |
|
|
| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. |
|
|
| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. |
|
|
| `GET /healthz` | Not part of the spec — liveness for compose. |
|
|
|
|
`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it
|
|
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
|
|
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
|
|
near-miss silently mis-correlates an installed host. Correlation rides on `ProductCode` instead,
|
|
which Inno gives us exactly (`<AppId>_is1`).
|
|
|
|
## Layout
|
|
|
|
| File | Role |
|
|
| --- | --- |
|
|
| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. |
|
|
| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. |
|
|
| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. |
|
|
| `test.mjs` | 28 checks driving `handler.mjs` directly. |
|
|
| `compose.production.yml` | The unom-1 service. |
|
|
|
|
## How it runs
|
|
|
|
```
|
|
Gitea releases (manifest trio per stable tag)
|
|
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
|
|
▼
|
|
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
|
|
▼
|
|
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
|
|
```
|
|
|
|
Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server
|
|
(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
|
|
a code change deploys by scp plus `docker compose up -d`.
|
|
|
|
The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to
|
|
Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`.
|
|
|
|
**Content and config deploy separately**, matching the flatpak repo:
|
|
|
|
- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files.
|
|
- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag.
|
|
|
|
`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart.
|
|
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
|
|
|
|
### Why the catalogue is derived from releases
|
|
|
|
`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes
|
|
`winget install --version` and `winget upgrade` work: winget resolves both against the version
|
|
*list*, so a source that only knew the newest release could neither pin an older version nor show an
|
|
upgrade path from one.
|
|
|
|
It also means the source holds no state — re-running the build reproduces the same `data.json`, so a
|
|
lost deployment is one command away and nothing can drift.
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
npm install
|
|
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
|
|
npm test # 28 checks, no network, no server
|
|
npm start # http://localhost:3240
|
|
```
|
|
|
|
`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no
|
|
package found", so a shape regression looks like a missing package rather than a broken source. CI
|
|
runs it before anything reaches the box.
|
|
|
|
Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly
|
|
trusted certificate, which is what the edge vhost provides in production.
|
|
|
|
## First-time setup
|
|
|
|
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
|
(DNS-only, same as `docs`). ✅ *done*
|
|
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
|
|
|
```caddyfile
|
|
winget.punktfunk.unom.io {
|
|
reverse_proxy 127.0.0.1:3240
|
|
}
|
|
```
|
|
|
|
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
|
for a name it does not serve. That is the expected state, not a broken deploy.
|
|
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
|
a catalogue exists — expected, and visible on `/healthz`.
|
|
4. Publish a stable tag, or build and ship the catalogue by hand:
|
|
|
|
```bash
|
|
cd packaging/winget/server && npm install && npm run build && npm test
|
|
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
|
|
```
|
|
|
|
Then verify from anywhere:
|
|
|
|
```bash
|
|
curl -s https://winget.punktfunk.unom.io/information | jq .
|
|
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
|
|
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
|
|
```
|
|
|
|
> Releases published before winget support shipped have no manifests attached, so `npm run build`
|
|
> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the
|
|
> checked-in manifests, or backfill the manifest trio onto an existing release.
|