feat(updater): add GitHub Releases source and route felis-api/k3s/cloudflared

Give RoutingSource its second upstream so every non-pinned component now
resolves a real latest-stable: Velocity via PaperMC (already wired), and
felis-api, k3s and cloudflared via the GitHub REST API.

github.go queries /repos/{repo}/releases/latest (one request, rate-limit
friendly) and fails closed: a transport error, a non-200 status (404 = no
stable release), an undecodable body, a draft/prerelease flag, or an
unparseable / prerelease-parsing tag all return an error, never a zero
version. It sends the User-Agent GitHub requires (a UA-less request is
403'd) and tolerates the two live tag styles -- cloudflared's CalVer
"2026.6.1" and k3s's v-prefixed, build-tagged "v1.36.2+k3s1" -- while
String() keeps the raw tag for the report.

source.go routes sourceGitHub to it and drops the errGitHubNotWired stub;
velocity still routes to PaperMC.

Tests: github_test.go covers both tag styles, the User-Agent gate, and
fail-closed on 404 / prerelease-flag / unparseable tag, with fixtures
captured from api.github.com on 2026-07-05. runner_test.go now drives
PaperMC and GitHub through dual httptest servers end to end with no source
degrading to an error.

doc.go re-tiers the verification boundary: both release sources are now
built and live-grounded; the VersionGatherer's version-extraction core is
the next verifiable slice (logic over an exec seam, not pure I/O); the
genuine I/O remainder is the Notifier, Applier and felis update CLI/CronJob.
felis-api's coord is still a placeholder slug, so that component is dark at
runtime until a real repository is configured.
This commit is contained in:
flyemoji committed 2026-07-05 01:03:32 +09:00
1 parent 9896fe16c3
commit 7d27640c07
5 files changed
+333 -54

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
package updater
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// These fixtures are the meaningful slice of GET /repos/{repo}/releases/latest, captured
// from the live GitHub REST API on 2026-07-05. They exercise the two real tag styles
// Felis must handle: cloudflared ships a CalVer tag ("2026.6.1") and k3s a v-prefixed tag
// carrying build metadata ("v1.36.2+k3s1"). The k3s list at that time also showed the
// prerelease pattern this source must reject — rc builds are tagged "-rcN" AND flagged
// "prerelease": true — which the fail-closed tests below reproduce.
const (
cloudflaredLatestFixture = `{"tag_name":"2026.6.1","prerelease":false,"draft":false,"name":"2026.6.1"}`
k3sLatestFixture = `{"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false,"name":"v1.36.2+k3s1"}`
)
func newTestGitHub(srv *httptest.Server) github {
return github{
baseURL: srv.URL,
userAgent: "felis-updater/0.1",
hc: srv.Client(),
}
}
// ghFixtureServer serves a fixed body to any /repos/.../releases/latest path, and — like
// the real API — 403s a request that arrives without a User-Agent.
func ghFixtureServer(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}
// TestGitHubLatestStableParsesRealTags proves both live tag styles reduce to the right
// stable Version: cloudflared's CalVer and k3s's v-prefixed, build-tagged tag. It also
// pins that String() preserves the raw upstream tag (so "+k3s1" reaches the report),
// while the numeric core is what comparison uses.
func TestGitHubLatestStableParsesRealTags(t *testing.T) {
cases := []struct {
name string
repo string
body string
wantMajMinPat [3]int
wantRawInReport string
}{
{"cloudflared CalVer", "cloudflare/cloudflared", cloudflaredLatestFixture, [3]int{2026, 6, 1}, "2026.6.1"},
{"k3s v-prefix +build", "k3s-io/k3s", k3sLatestFixture, [3]int{1, 36, 2}, "v1.36.2+k3s1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := ghFixtureServer(tc.body)
defer srv.Close()
v, err := newTestGitHub(srv).latestStable(context.Background(), tc.repo)
if err != nil {
t.Fatalf("latestStable: %v", err)
}
if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantMajMinPat {
t.Errorf("version core = %v, want %v", got, tc.wantMajMinPat)
}
if v.IsPrerelease() {
t.Errorf("stable release parsed as prerelease: %q", v.String())
}
if v.String() != tc.wantRawInReport {
t.Errorf("String() = %q, want raw upstream tag %q preserved for the report", v.String(), tc.wantRawInReport)
}
})
}
}
// TestGitHubClearsTheUserAgentGate proves Felis's request carries the User-Agent GitHub
// requires: the fixture server refuses a UA-less request with 403 exactly as the live API
// does, so a successful discovery is evidence the client sent one.
func TestGitHubClearsTheUserAgentGate(t *testing.T) {
var gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
if gotUA == "" {
http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden)
return
}
_, _ = w.Write([]byte(cloudflaredLatestFixture))
}))
defer srv.Close()
v, err := newTestGitHub(srv).latestStable(context.Background(), "cloudflare/cloudflared")
if err != nil {
t.Fatalf("latestStable through the UA gate: %v", err)
}
if v.String() != "2026.6.1" {
t.Errorf("latestStable = %q, want 2026.6.1", v.String())
}
if gotUA == "" {
t.Fatal("no User-Agent sent — the real GitHub API would have refused this request")
}
}
// TestGitHubFailsClosedOn404 proves a repo with no stable release (GitHub returns 404
// from /releases/latest) is an error, not a bogus zero version.
func TestGitHubFailsClosedOn404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
}))
defer srv.Close()
if v, err := newTestGitHub(srv).latestStable(context.Background(), "acme/no-releases"); err == nil {
t.Fatalf("want error on 404, got version %q", v.String())
}
}
// TestGitHubFailsClosedOnPrereleaseFlag proves the defensive re-check holds: even if the
// endpoint ever returned a release flagged prerelease, Felis refuses it rather than
// promoting an rc into a scheduled apply.
func TestGitHubFailsClosedOnPrereleaseFlag(t *testing.T) {
const rc = `{"tag_name":"v1.37.0-rc1+k3s1","prerelease":true,"draft":false}`
srv := ghFixtureServer(rc)
defer srv.Close()
if v, err := newTestGitHub(srv).latestStable(context.Background(), "k3s-io/k3s"); err == nil {
t.Fatalf("want error on a prerelease-flagged release, got %q", v.String())
}
}
// TestGitHubFailsClosedOnUnparseableTag proves a tag that is not a version (some repos
// tag "nightly" / "latest") is an error, not a silent zero.
func TestGitHubFailsClosedOnUnparseableTag(t *testing.T) {
const junk = `{"tag_name":"nightly","prerelease":false,"draft":false}`
srv := ghFixtureServer(junk)
defer srv.Close()
if v, err := newTestGitHub(srv).latestStable(context.Background(), "acme/rolling"); err == nil {
t.Fatalf("want error on an unparseable tag, got %q", v.String())
}
}