Merge pull request 'docs(installer): record Phase 0.3 — the bundle CI and where bundles live' (#85) from docs/installer-phase-0.3 into main

Reviewed-on: #85
This commit is contained in:
2026-08-04 16:20:05 +00:00

View File

@@ -1,6 +1,9 @@
# Runic Gateway Installer — plan # Runic Gateway Installer — plan
Status: **Phase 0 in progress.** No installer code exists yet. This document is the design of record; Status: **Phase 0 all but complete** — every prerequisite in another repo has landed, and the
installer repo now publishes the bundle manifest, so *what* the installer will install is already
released and composed ahead of the binary that installs it. No installer code exists yet; `0.4`
(`INSTALL.md`) is the remaining item, then Phase 1. This document is the design of record;
it supersedes the informal overview it grew out of, which described a ServUO integration that does it supersedes the informal overview it grew out of, which described a ServUO integration that does
not match how `servuo-plugins` actually ships (see not match how `servuo-plugins` actually ships (see
[Corrections](#corrections-to-the-original-overview)). [Corrections](#corrections-to-the-original-overview)).
@@ -8,9 +11,9 @@ not match how `servuo-plugins` actually ships (see
| Phase 0 item | State | | Phase 0 item | State |
|---|---| |---|---|
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) | | 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
| 0.2 `link` installable (data paths + `--print-config`) | 🟨 In review — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) | | 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
| 0.3 Bundle CI in the installer repo | ⬜ Not started — **next**; both components it composes now exist | | 0.3 Bundle CI in the installer repo | 🟨 In review — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: `2026.08.04` |
| 0.4 This file + `INSTALL.md` | 🟦 This file exists; `INSTALL.md` waits on the shape settling | | 0.4 This file + `INSTALL.md` | 🟦 This file exists; `INSTALL.md` is **next** the shape has now settled |
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) | | — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
--- ---
@@ -298,6 +301,39 @@ Repo work that must land before an installer can exist.
two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each
component's release workflow. This must exist before Phase 1 is useful, since the installer component's release workflow. This must exist before Phase 1 is useful, since the installer
resolves what to install *from* the bundle. resolves what to install *from* the bundle.
As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3),
[link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25),
[servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) —
`installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open:
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
where and why. That was the one genuinely open question here, and the deciding factor is that
this repo's *own* releases are the installer binaries.
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
`sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution
of a downloaded artifact, and does not provision a throwaway config whose auth token would then
be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating
"could not read" as "matches" is exactly how a mismatched pair would ship.
- **Gate 2 records the hash CI computed itself**, after verifying the download against the
publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no*
`SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not
mention, which would put an unverified artifact in the bundle.
- **Release metadata is read anonymously**, on purpose: those are exactly the requests the
shipped installer makes on a host with no Gitea credentials, so a repo flipped to private
fails CI here instead of on an operator's machine.
- **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys
by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job
rather than silently omitting the new binary from every bundle.
- **A run that changes nothing writes nothing** — the comparison excludes `bundle` and
`generated`, which are metadata about the run. Without that the nightly cron would commit a
dated duplicate of the same matrix every morning.
The workflow's compose steps were run against the live releases before merge, producing the
first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so
the manifest exists ahead of the binary that reads it.
4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the 4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the
shape is settled. shape is settled.
@@ -471,15 +507,33 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
```json ```json
{ {
"bundle": "2026.08.01", "schema": 1,
"bundle": "2026.08.04",
"generated": "2026-08-04T16:07:13Z",
"protocol": 3, "protocol": 3,
"link": { "version": "0.3.0", "sha256": "a91f..." }, "link": {
"overlay": { "version": "0.1.0", "commit": "968b526", "sha256": "7c3e..." } "repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
"assets": {
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
}
},
"overlay": {
"repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
"commit": "3a52abb…", "protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
}
} }
``` ```
Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
hash could only ever have described one of them. `schema` versions this document's shape and is
independent of `protocol` and of either component's release version — all three move separately.
The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
reproducible install. Because the bundle is data, **a new `link` release regenerates ~20 lines of reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of
JSON and leaves the installer binary untouched** — operators do not re-download the installer to JSON and leaves the installer binary untouched** — operators do not re-download the installer to
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical. pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.
@@ -487,19 +541,53 @@ Two gates run at compose time, both cheap and both worth it:
1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version. 1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version.
This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator. This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator.
The two halves are read from different places because they *are* different: the overlay's from
`manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's
from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary).
2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`. 2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`.
The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that
check — and the installer verifies every download against it. These artifacts are deliberately
unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody
verified would make the chain decorative.
#### Where bundles are published
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
`GET` against a public repo — the shard host has no Gitea credentials (§1):
```
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
```
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
already requires the CI user to be able to push to `main`.
### 7.2 What triggers a bundle ### 7.2 What triggers a bundle
| Trigger | Why | | Trigger | Why |
|---|---| |---|---|
| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step. `link/.gitea/workflows/release.yml` already declares `workflow_dispatch: {}` and already holds a `write:repository` token | | `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step |
| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step is marked as a TODO in that workflow's header and lands with the bundle CI it would call (item 3) — a step that `404`s on every release is worse than no step | | `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step |
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar | | Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |
`repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version, `repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version,
whereas dispatching an existing `workflow_dispatch` workflow via the API works today. whereas dispatching an existing `workflow_dispatch` workflow via the API works today.
**A failed dispatch is a warning, never a failed release.** By the time that step runs the component
release is published and correct; failing the job would misreport it. This also keeps the dispatch
from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
costs latency, not correctness, which is the whole reason the cron exists.
### 7.3 Stale-overlay handling: dispatch, don't wait ### 7.3 Stale-overlay handling: dispatch, don't wait
Each component **self-releases on merge to its own `main`**, using the same conventional-commit Each component **self-releases on merge to its own `main`**, using the same conventional-commit
@@ -507,6 +595,12 @@ engine. Note that "updated since the last release" must mean *releasable* commit
`RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not** `RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not**
cut an overlay release, and the bundle keeps using the existing one. cut an overlay release, and the bundle keeps using the existing one.
The compose job's copy of that rule additionally **excludes merge commits**, whose subject is
`Merge pull request '<the real subject>'`. Without that, every squash-free merge of a `feat:` branch
would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a
`fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly
declines to run.
So by the time the installer's CI looks, the release normally already exists. If it finds So by the time the installer's CI looks, the release normally already exists. If it finds
`servuo-plugins` main ahead of its latest release *with* releasable commits, it: `servuo-plugins` main ahead of its latest release *with* releasable commits, it: