Files
Felis/internal/updater/github.go
T
flyemoji 7d27640c07 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.
2026-07-05 01:03:32 +09:00

113 lines
4.7 KiB
Go

package updater
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"felis.lolicon.best/internal/updates"
)
// githubBaseURL is the GitHub REST API root. It is a field on the github source (not a
// hardcoded constant in the request) so discovery runs against an httptest server in
// tests without touching the network.
const githubBaseURL = "https://api.github.com"
// github discovers the latest STABLE release of a GitHub repository from the REST API's
// /repos/{repo}/releases/latest endpoint, which GitHub defines as the most recent
// non-draft, non-prerelease release. Felis tracks felis-api, k3s and cloudflared this
// way.
//
// Unlike the PaperMC Fill API, GitHub ENFORCES a User-Agent: a request without one is
// answered "403 Request forbidden by administrative rules. Please make sure your request
// has a User-Agent header" (verified against api.github.com on 2026-07-05 — a UA-less
// request 403'd while a plain browser UA got 200, so the gate is on presence, not on the
// UA's content). Felis always sends its descriptive UA, so the requirement is met.
//
// It uses /releases/latest (one request per repo) rather than listing releases: it is
// GitHub's own "newest stable" definition and is gentle on the unauthenticated 60-req/h
// rate limit a CronJob shares. The one semantic gap — GitHub sorts "latest" by the
// release's created_at (not by version), so a repo that back-ports a patch to an OLD
// line LAST would report that patch — does not bite Felis's tracked repos: felis-api
// and cloudflared are
// single-line (date order == version order), and k3s is Notify-only (a missed
// notification self-corrects on the next release, and never drives an apply).
type github struct {
baseURL string // e.g. "https://api.github.com"
userAgent string // MUST be non-empty — GitHub 403s a UA-less request
hc *http.Client
}
// newGitHub builds a source pointed at the live GitHub REST API with sane defaults.
func newGitHub() github {
return github{
baseURL: githubBaseURL,
userAgent: defaultUserAgent,
hc: &http.Client{Timeout: 15 * time.Second},
}
}
// releaseResponse is the slice of GET /repos/{repo}/releases/latest that Felis reads.
// The live shape (2026-07-05) for both a CalVer project (cloudflared "2026.6.1") and a
// v-prefixed, build-tagged one (k3s "v1.36.2+k3s1") is:
//
// {"tag_name":"v1.36.2+k3s1","prerelease":false,"draft":false, ...}
//
// updates.Parse tolerates the leading "v" and strips "+build" metadata, so both tag
// styles reduce to a comparable Version while String() keeps the raw for the report.
type releaseResponse struct {
TagName string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
// latestStable returns the newest STABLE release of repo (e.g. "cloudflare/cloudflared").
//
// It fails closed: a transport error, a non-200 status (GitHub answers 404 when a repo
// has no non-prerelease release, so "no stable release" surfaces as an error, never a
// zero version), an undecodable body, a response that is somehow marked draft/prerelease,
// or an unparseable / prerelease-parsing tag all return an error. /releases/latest
// already excludes drafts and prereleases; the explicit re-checks are defense in depth so
// an upstream change can never silently promote a prerelease into a scheduled apply.
func (g github) latestStable(ctx context.Context, repo string) (updates.Version, error) {
url := fmt.Sprintf("%s/repos/%s/releases/latest", g.baseURL, repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return updates.Version{}, fmt.Errorf("github: build request for %s: %w", repo, err)
}
ua := g.userAgent
if ua == "" {
ua = defaultUserAgent
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := g.hc.Do(req)
if err != nil {
return updates.Version{}, fmt.Errorf("github: get %s: %w", repo, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
}
var rr releaseResponse
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil {
return updates.Version{}, fmt.Errorf("github: decode %s: %w", repo, err)
}
if rr.Draft || rr.Prerelease {
return updates.Version{}, fmt.Errorf("github: %s releases/latest is unexpectedly draft/prerelease (tag %q)", repo, rr.TagName)
}
v, err := updates.Parse(rr.TagName)
if err != nil {
return updates.Version{}, fmt.Errorf("github: parse tag %q for %s: %w", rr.TagName, repo, err)
}
if v.IsPrerelease() {
return updates.Version{}, fmt.Errorf("github: %s latest tag %q parses as a prerelease", repo, rr.TagName)
}
return v, nil
}