Three things, all found by the first compose run that ever had a bundle
to write.
1. `main` is protected, so the push was declined by the pre-receive
hook -- twice, since the retry rebases and pushes to the same place.
Every bundle since v1.1.1 has been composed correctly and thrown
away. Bundles now go to a `bundles` branch of their own, at its
root, which needs no protection exception and keeps everything the
original choice was for: a reviewable diff, a git history of the
compat matrix, plain anonymous raw URLs, no credentials on the shard
host. The header's claim that this push "needs no new
branch-protection exception" was simply false.
2. A `${{ }}` written literally in a shell comment silently disabled
the entire stale-component check. The runner scans a step's script
for template expressions before running it, fails to parse the empty
one, and skips the step WITHOUT failing the job -- so the dispatch
that is supposed to fire a component's release workflow has never
run once. Reworded, with a warning not to write that token in a
comment again. (link/release.yml and this repo's release.yml carry
the same bug in their bump-and-tag step; handled separately.)
3. linux-aarch64 is now a REQUIRED platform key, which was step 3 of
PLAN.md §5.2 and was waiting on link publishing one. v1.1.1 does, so
from here a dropped target reddens this job instead of vanishing
from every bundle.
The published bundles are materialized into a worktree at `published/`,
so the ".2 suffix" scan and the idempotence check read what is actually
published rather than a stale copy on main. The branch is created from
an empty-tree root commit on first use, so it carries no history that
has nothing to do with the compat matrix; it has been seeded already
with bundle 2026.08.04, because every bundle is kept forever and the
move must not lose the one that exists.
bundles/*.json is deleted from main -- it is now a stale copy of data
that lives elsewhere, and a wrong "current" is worse than none. The
README stays and documents the branch.
Verified by running the whole job in a container against a bare repo
standing in for the remote: first run creates the branch and publishes
both files with all three asset keys, second and third runs report
"identical to the published current.json -- nothing to publish" and
push nothing, and the stale check now runs and reports both components
as having nothing releasable.
Co-Authored-By: Claude <noreply@anthropic.com>
Step 1 of PLAN.md §5.2's four, and it has to be first. Two rules in this
job are strict in opposite directions: an unrecognized link asset name
fails the run, and a missing REQUIRED platform key fails it too. So the
name must be taught before the release that carries it, and the key can
only be required after one exists -- requiring it first would fail every
bundle for as long as the gap lasts.
This is therefore the mapping only. linux-aarch64 is not in REQUIRED
yet; step 3 promotes it once a link release actually ships the binary,
after which a dropped target reddens CI instead of vanishing silently
from every bundle.
The compose step needed no change: it builds the asset map from the
platform TSV, so a third key costs it nothing.
Edited on `main` and deliberately not on `edge`. The compose job runs
from `main`, and leaving `edge`'s copy untouched means the eventual
cutover merge has nothing to conflict over.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
servuo-plugins hit both of these on its first real release run; this repo
runs the same engine, so it has the same two defects latent.
REGISTRY_USER / REGISTRY_TOKEN were empty there, yet the tag push
SUCCEEDED: actions/checkout leaves an `http.<host>.extraheader` credential
in the local git config, so `git remote set-url` to a URL with empty
credentials still authenticated through that leftover header. The release
API call had no such fallback and returned 401. Net result was the worst
available outcome — the repo tagged, no release, and a failed job.
Two fixes:
A credential preflight, before anything is built or pushed, gated on the
run actually intending to publish so a docs:/chore:-only merge (or this
repo's pre-crate no-op) still passes on a repo with no secrets. It names
the missing secrets and the scope they need instead of failing wherever
they happen to be used first.
Orphan-tag recovery. A tag with no release behind it means an earlier run
died after tagging, and the old code treated any existing tag as "nothing
to release" — so that state could never clear itself: every later run
would see the tag and stand down, forever. The plan step now asks the API
whether a release exists for the tag, and on 404 reuses the tag and
publishes the release it is missing. This deliberately overrides the
RELEASE=false the bump logic just decided, which is the whole point —
with the tag in place there are no releasable commits after it.
Anything other than 200/404 (network failure, bad token) is refused
rather than guessed, since assuming "no release" would republish over a
good one.
The tag step now reuses an existing tag instead of failing on `git tag`,
and the changelog for a recovery run summarizes what the tag contains
(previous-tag..this-tag) rather than the empty range after it.
sync-project-tree gets the same preflight: its first run on main failed
with an opaque `git clone` error against `https://:@host/...` that said
nothing about a missing secret.
Verified by extracting every run block and exercising the paths: empty
secrets fail the preflight with a legible message and populated ones pass;
the no-Cargo.toml guard still short-circuits to release=false; a crate with
no tag still takes the seed path; and against real repo state, a tag with a
release stands down while an orphan tag recovers.
Co-Authored-By: Claude <noreply@anthropic.com>
Bring this repo's CI up to parity with the other Runic Gateway repos. All
three are retargeted from RunicGateway/link, which is the closest analog
(same Rust toolchain, same release engine, same runner).
pr-checks.yml
Gates PRs into main on cargo fmt --check, clippy -D warnings, and
cargo test --locked, in that order, one job — mirroring release.yml's
gates so a green PR implies a green release.
release.yml
The conventional-commit release engine from link/, with the Rust
adapter retargeted: crate at the repo root, binary
runicgateway-installer, cross-compiled for x86_64 Linux and Windows.
Artifact names follow PLAN.md §3. The generated changelog now carries
the checksum-verification block, because releases are deliberately
unsigned and SHA256SUMS is the trust anchor (PLAN.md §3) — that makes
the verify instructions part of the release, not a doc someone has to
find.
sync-project-tree.yml (+ .gitea/scripts/gen_tree.py)
Regenerates docs/installer/PROJECT_TREE.md on every push to main and
opens or force-updates a PR against the docs repo. Verbatim from link/
apart from the repo/path/label env block.
Crate guard
This repo has no Cargo project yet — Phase 1 creates it. Landing the
workflows unguarded would red-X every governance and docs PR until
then, and holding them back leaves the repo ungated exactly while its
conventions are being set. So both Rust workflows check for a root
Cargo.toml first: pr-checks skips its gates with a notice, and
release.yml's plan step sets RELEASE=false and exits. Both arm
themselves the moment Cargo.toml lands, with no edit here.
Verified before pushing: all three files parse as YAML, every run block
passes bash -n, and the release plan step was simulated against a throwaway
git repo both without a crate (release=false, exit 0) and with one
(first-release path -> v0.1.0 with the changelog rendered).
Not included: the bundle-manifest workflow (PLAN.md §7) and the
release-dispatch hook, which are Phase 0 item 3 and depend on
servuo-plugins having a release workflow first.
Note for setup: release.yml and sync-project-tree.yml need REGISTRY_USER
and REGISTRY_TOKEN (write:repository, plus read/write on RunicGateway/docs)
configured for this repo.
Co-Authored-By: Claude <noreply@anthropic.com>
The installer repo was created empty. Seed it with the same governance set
every other Runic Gateway repo carries, so it starts on the same footing
before any Rust code lands (see docs/installer/PLAN.md for the design of
record — this repo is still in the planning phase).
Copied verbatim, byte-identical to the other repos:
LICENSE.md (GPL-3.0-or-later), CODE_OF_CONDUCT.md, CONTRIBUTORS.md,
.gitea/PULL_REQUEST_TEMPLATE.md, .gitea/ISSUE_TEMPLATE/{bug_report,
feature_request}.md
Repo-specific:
README.md what the installer is, what it deliberately is not
(no curl|bash, never writes a ServUO launcher), the
planned commands, and the constraints a reader needs
up front: unsigned releases, bundle-manifest
composition, opt-in patch tier, and the fact that a
successful copy is not a working bridge.
CONTRIBUTING.md adapted from link/ (same Rust toolchain and checks),
plus a planning-status note pointing changes of scope
at the plan in docs/, and the two shard-testing traps.
SECURITY.md adds the installer to the component scope table and a
short subsection on its distinct trust model: unsigned
releases anchored on SHA256SUMS, mandatory
verification of downloaded artifacts, and the
never-contacts-the-website token handoff. This is the
only file that now differs from the other repos' copies.
.gitignore Rust build output plus local deployment state
(install.json, sidecar.toml, *.db) that must never be
committed from a test run.
.gitea/ISSUE_TEMPLATE/config.yaml same as elsewhere, repo-local URL.
No CI workflows yet — there is no crate for pr-checks to build, and the
release/bundle workflows are Phase 0 work that depends on servuo-plugins
gaining a release workflow first.
Co-Authored-By: Claude <noreply@anthropic.com>