ci(bundle): compose and publish the bundle manifest #3

Merged
whitlocktech merged 1 commits from ci/bundle-compose into main 2026-08-04 16:19:40 +00:00
Member

Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3): "Compose job (read both repos' latest releases → run the two gates → publish bundle.json), the nightly cron, and the dispatch step appended to each component's release workflow."

Companion PRs (the dispatch half): RunicGateway/link#25, RunicGateway/servuo-plugins#9. Docs: RunicGateway/docs#85.

Why

The installer resolves what to install from the bundle — it hardcodes no versions and does not resolve "latest" at run time — so this has to exist before Phase 1 code is useful. It was blocked until both components had releases; they now do (link v1.1.0, servuo-plugins v0.1.1), which is what unblocked it.

What landed

.gitea/workflows/bundle.yml — triggered by workflow_dispatch (POSTed by each component's release job) and a nightly cron. It resolves both components' latest releases, runs the two compose-time gates, and commits bundles/current.json.

The first real bundle is committed, so the manifest exists ahead of the binary that reads it:

{
  "schema": 1,
  "bundle": "2026.08.04",
  "protocol": 3,
  "link":    { "tag": "v1.1.0", "assets": { "linux-x86_64": {}, "windows-x86_64": {} } },
  "overlay": { "tag": "v0.1.1", "commit": "3a52abb…", "servuo": { "min_version": "57.4",  } }
}

Note link.assets is a map keyed by platform, not the single sha256 PLAN.md §7.1 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.

The decisions §7 left open

Bundles are committed under bundles/, not published as Gitea releases. This was the one genuinely open question. 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 gives 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. The installer's fetch stays a plain anonymous GET on a public repo, which it must be — the shard host has no Gitea credentials.

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 ships.

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 passes right over a file the sums file does not mention. These artifacts are deliberately unsigned, so the checksum is the entire trust anchor; a hash copied from a file nobody verified would make the chain decorative.

Release metadata is read anonymously, on purpose. Those are exactly the requests the shipped installer makes on a host with no 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.

Stale components are dispatched, never awaited (§7.3). Gitea's dispatch endpoint returns no run handle, so a waiting job would have to guess which run is its own while holding a runner idle. "Ahead of its release" counts only releasable commits and excludes merge commits, whose subject is Merge pull request '<the real subject>' — without that exclusion a merge of a docs: branch whose title quotes a fix: would re-dispatch, every night, a release workflow that correctly declines to run.

Verification

The workflow's compose steps were extracted and run against the live releases in a Linux container (matching the runner, not Git Bash):

  • both gates pass; the composed document is byte-for-byte the bundles/bundle-2026.08.04.json committed here;
  • idempotence: an immediate re-run reports changed=false and writes nothing;
  • gate 2 verified both link binaries and the overlay tarball against their published SHA256SUMS;
  • gate 1 read protocol=3 from sidecar/src/main.rs@v1.1.0 and protocol=3 from manifest.json inside the overlay tarball;
  • the manifest-version cross-check (manifest.version vs the release tag) passes on v0.1.1.

Stale-detection was tested against real compare data and synthetic ranges:

Range Total commits Scored releasable
link v1.0.0...main 5 1 (the feat:; the merge quoting it and the ci: correctly ignored)
link v1.1.0...main 0 0
servuo-plugins v0.1.1...main 0 0
synthetic: refactor!:, body BREAKING CHANGE, fix: + noise 7 3
synthetic: docs: + its merge commit 2 0 (PLAN.md §7.3)

All four workflow YAML files parse.

AI-assisted contribution

  • This PR was written with AI assistance (Claude Code / Claude Opus 5); commits carry the Co-Authored-By trailer.
Phase 0 item 3 of [`docs/installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) (§7.1–§7.3): *"Compose job (read both repos' latest releases → run the two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each component's release workflow."* Companion PRs (the dispatch half): RunicGateway/link#25, RunicGateway/servuo-plugins#9. Docs: RunicGateway/docs#85. ## Why The installer resolves what to install **from the bundle** — it hardcodes no versions and does not resolve "latest" at run time — so this has to exist before Phase 1 code is useful. It was blocked until both components had releases; they now do (`link` `v1.1.0`, `servuo-plugins` `v0.1.1`), which is what unblocked it. ## What landed **`.gitea/workflows/bundle.yml`** — triggered by `workflow_dispatch` (POSTed by each component's release job) and a nightly cron. It resolves both components' latest releases, runs the two compose-time gates, and commits `bundles/current.json`. **The first real bundle is committed**, so the manifest exists ahead of the binary that reads it: ```json { "schema": 1, "bundle": "2026.08.04", "protocol": 3, "link": { "tag": "v1.1.0", "assets": { "linux-x86_64": {…}, "windows-x86_64": {…} } }, "overlay": { "tag": "v0.1.1", "commit": "3a52abb…", "servuo": { "min_version": "57.4", … } } } ``` Note `link.assets` is a **map keyed by platform**, not the single `sha256` PLAN.md §7.1 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. ## The decisions §7 left open **Bundles are committed under `bundles/`, not published as Gitea releases.** This was the one genuinely open question. 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 gives 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`. The installer's fetch stays a plain anonymous `GET` on a public repo, which it must be — the shard host has no Gitea credentials. **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 ships. **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` passes right over a file the sums file does not mention. These artifacts are deliberately unsigned, so the checksum is the entire trust anchor; a hash copied from a file nobody verified would make the chain decorative. **Release metadata is read anonymously, on purpose.** Those are exactly the requests the shipped installer makes on a host with no 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. **Stale components are dispatched, never awaited** (§7.3). Gitea's dispatch endpoint returns no run handle, so a waiting job would have to guess which run is its own while holding a runner idle. "Ahead of its release" counts only *releasable* commits and excludes merge commits, whose subject is `Merge pull request '<the real subject>'` — without that exclusion a merge of a `docs:` branch whose title quotes a `fix:` would re-dispatch, every night, a release workflow that correctly declines to run. ## Verification The workflow's compose steps were extracted and run **against the live releases in a Linux container** (matching the runner, not Git Bash): - both gates pass; the composed document is byte-for-byte the `bundles/bundle-2026.08.04.json` committed here; - **idempotence**: an immediate re-run reports `changed=false` and writes nothing; - gate 2 verified both link binaries and the overlay tarball against their published `SHA256SUMS`; - gate 1 read `protocol=3` from `sidecar/src/main.rs@v1.1.0` and `protocol=3` from `manifest.json` inside the overlay tarball; - the manifest-version cross-check (`manifest.version` vs the release tag) passes on `v0.1.1`. Stale-detection was tested against real compare data and synthetic ranges: | Range | Total commits | Scored releasable | |---|---|---| | `link v1.0.0...main` | 5 | **1** (the `feat:`; the merge quoting it and the `ci:` correctly ignored) | | `link v1.1.0...main` | 0 | 0 | | `servuo-plugins v0.1.1...main` | 0 | 0 | | synthetic: `refactor!:`, body `BREAKING CHANGE`, `fix:` + noise | 7 | **3** | | synthetic: `docs:` + its merge commit | 2 | **0** (PLAN.md §7.3) | All four workflow YAML files parse. ## AI-assisted contribution - [x] This PR was written with AI assistance (Claude Code / Claude Opus 5); commits carry the `Co-Authored-By` trailer.
wtclaude added 1 commit 2026-08-04 16:16:08 +00:00
ci(bundle): compose and publish the bundle manifest
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
c100d4a2d7
Phase 0 item 3 of docs/installer/PLAN.md (§7.1-§7.3). The installer resolves
what to install *from* the bundle, so this has to exist before Phase 1 code is
useful. Both components it composes now have releases, which is what unblocked
it.

Adds .gitea/workflows/bundle.yml — resolve both components' latest releases,
run the two compose-time gates, and publish bundles/current.json — plus the
first real bundle (2026.08.04: link v1.1.0 + overlay v0.1.1, protocol 3).

Bundles are COMMITTED under bundles/, not published as releases. This repo's own
releases are the installer binaries, and /releases/latest returns whichever
release is newest regardless of kind, so interleaving bundle releases would make
"latest" intermittently resolve to a release carrying no installer binary. The
push to main needs no new branch-protection exception: release.yml's version-bump
commit already requires it.

Gate 1 (protocol agreement) reads the sidecar's PROTOCOL_VERSION from
sidecar/src/main.rs at the release tag, not from the binary. --print-config
would answer, but only for releases from v1.1.0 on, and --bundle <tag> has to be
able to recompose an older bundle. It also avoids executing a downloaded
artifact and provisioning a throwaway config whose auth token would land in a CI
log. The overlay half comes from manifest.json inside the tarball, which is the
only statement of that version that exists.

Gate 2 (assets) downloads every asset and verifies it against the SHA256SUMS its
publishing repo shipped, then records the hash it computed itself. These
artifacts are deliberately unsigned, so a hash copied from a file nobody checked
would make the whole chain decorative. An asset with no SHA256SUMS entry is
caught separately, since `sha256sum -c` passes right over it.

Release reads are ANONYMOUS on purpose: they are exactly the requests the shipped
installer makes on a host with no Gitea credentials, so a repo flipped to private
fails here rather than on an operator's machine.

Stale components (§7.3) are dispatched, never awaited — Gitea's dispatch endpoint
returns no run handle. "Ahead of its release" counts only releasable commits and
excludes merge commits, whose subject quotes the feat/fix title and would
otherwise re-dispatch a workflow that correctly declines to run, every night.

A run that finds nothing changed writes nothing, comparing everything except
`bundle` and `generated` — that is what makes the nightly cron free rather than a
dated duplicate every morning.

Verified by running the workflow's exact compose steps in a Linux container
against the live releases: both gates pass, the composed bundle is the file
committed here, a re-run reports changed=false, and the stale-detection filter
scores 1 releasable commit for link v1.0.0..main (excluding the merge that quotes
it) and 0 for a docs-only range.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-04 16:19:20 +00:00
whitlocktech merged commit 0e7d5f3bee into main 2026-08-04 16:19:40 +00:00
whitlocktech deleted branch ci/bundle-compose 2026-08-04 16:19:41 +00:00
Sign in to join this conversation.
No description provided.