feat(updater): authenticate GitHub polling and track the real release repo

felis-api's coord was the placeholder "felis/felis", which resolves against
nothing on real GitHub. It is now MliroLirrorsIngenuity/Felis — the same slug
deploy/bootstrap.sh clones from — so update reporting for the control plane
itself is live rather than parked.

That repo is private today, so the github source gained an optional token, read
from FELIS_GITHUB_TOKEN: the variable bootstrap already needs, so an operator
sets one value once. It comes from the environment and is never compiled in. A
constant would be committed to the very repository it protects, ship inside
every felis binary where strings(1) recovers it, reach every node the image is
imported onto, and need a rebuild and a redeploy to rotate.

Empty stays the correct posture for the other tracked components — k3s and
cloudflared are public — and an empty token sends no Authorization header at
all rather than an empty one.

GitHub answers 404, not 401 or 403, for a private repo the caller cannot see,
so "no token" and "no stable release published yet" arrive as the same status.
On an unauthenticated 404 the error now names both causes and the variable that
fixes the actionable one. With a token already set that hint would be wrong, so
it is suppressed.

Tests pin both halves: the Bearer header is sent only when the token is set,
and the diagnostic names the variable only when it is not.

doc.go's CAVEATS bullet still described the coord as a placeholder and the
component as "dark at runtime". Both were true only until this change; it now
records the real condition, which is that the component resolves like the others
but needs a credential while the repo is private.
This commit is contained in:
flyemoji committed 2026-07-20 18:53:12 +09:00
1 parent 659c8e5e9f
commit c4300cb005
6 files changed
+110 -14

No files matched your search

+5 -3
View File
@@ -25,9 +25,11 @@
// differ and the code reflects it: GitHub ENFORCES a UA (a bare request is 403'd,
// verified 2026-07-05) so Felis's UA is load-bearing there; PaperMC does NOT
// enforce (a bare request got HTTP 200 on 2026-07-04) so its UA is only etiquette.
// Also: felis-api's topology coord "felis/felis" is a PLACEHOLDER slug — the GitHub
// routing/parse logic is verified, but that one component stays dark at runtime (its
// Latest errors, degrading to "latest unknown") until a real repository is configured.
// Also: felis-api's topology coord is now the real repository slug, not a placeholder,
// so that component resolves at runtime like the others — but only with a credential.
// The repo is private, so an unauthenticated poll gets GitHub's 404-for-hidden-repo and
// degrades to "latest unknown"; FELIS_GITHUB_TOKEN is what lights it up. k3s and
// cloudflared are public and need none.
//
// - ALSO BUILT + UNIT-VERIFIED: the VersionGatherer's extraction core and dispatch.
// Three pure extractors turn raw system text into a Version — a `--version` banner
+32 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"felis.lolicon.best/internal/updates"
@@ -37,14 +38,32 @@ const githubBaseURL = "https://api.github.com"
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
// token is an optional credential. Empty means unauthenticated, which is the right
// posture for the public repos Felis tracks (k3s, cloudflared) and is what the
// 60-req/h note above is about. It is load-bearing only for felis-api's own repo
// while that repo is private, where its absence does not look like an auth failure:
// GitHub answers 404 — not 401 or 403 — for a private repo the caller cannot see, so
// "no token" is indistinguishable from "no release published yet" by status alone.
// latestStable says both in the error rather than making an operator guess.
//
// It is read from the environment and never compiled in. A constant would be
// committed to the very repository it protects, ship inside every felis binary where
// strings(1) recovers it, reach every node the image is imported onto, and need a
// rebuild and a redeploy to rotate.
token string
hc *http.Client
}
// tokenEnv names the environment variable holding the GitHub credential. deploy/bootstrap.sh
// reads the same variable to clone a private repo, so an operator sets one value once.
const tokenEnv = "FELIS_GITHUB_TOKEN"
// newGitHub builds a source pointed at the live GitHub REST API with sane defaults.
func newGitHub() github {
return github{
baseURL: githubBaseURL,
userAgent: defaultUserAgent,
token: os.Getenv(tokenEnv),
hc: &http.Client{Timeout: 15 * time.Second},
}
}
@@ -83,6 +102,9 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version,
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "application/vnd.github+json")
if g.token != "" {
req.Header.Set("Authorization", "Bearer "+g.token)
}
resp, err := g.hc.Do(req)
if err != nil {
@@ -90,6 +112,15 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version,
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 404 is the ambiguous one: GitHub hides a private repo behind it rather than
// answering 401/403, so an unauthenticated miss and a repo with no stable release
// are the same status. Name both causes, and name the fix for the one an operator
// can act on.
if resp.StatusCode == http.StatusNotFound && g.token == "" {
return updates.Version{}, fmt.Errorf(
"github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset",
repo, tokenEnv)
}
return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode)
}
+61
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -140,3 +141,63 @@ func TestGitHubFailsClosedOnUnparseableTag(t *testing.T) {
t.Fatalf("want error on an unparseable tag, got %q", v.String())
}
}
// TestGitHubSendsTokenOnlyWhenSet proves the credential reaches the wire as a Bearer
// header when present, and that an empty token sends NO Authorization header at all —
// the public repos (k3s, cloudflared) must keep working with no credential configured.
func TestGitHubSendsTokenOnlyWhenSet(t *testing.T) {
for _, tc := range []struct {
name, token, wantAuth string
}{
{"a token is sent as a Bearer credential", "ghp_secret", "Bearer ghp_secret"},
{"no token sends no Authorization header", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
_, _ = w.Write([]byte(`{"tag_name":"v1.2.3","prerelease":false,"draft":false}`))
}))
defer srv.Close()
g := newTestGitHub(srv)
g.token = tc.token
if _, err := g.latestStable(context.Background(), "acme/private"); err != nil {
t.Fatalf("latestStable: %v", err)
}
if gotAuth != tc.wantAuth {
t.Errorf("Authorization = %q, want %q", gotAuth, tc.wantAuth)
}
})
}
}
// TestGitHubNamesTheTokenOnAnUnauthenticated404 pins the diagnostic that makes a private
// repo debuggable. GitHub hides a repo the caller cannot see behind 404 rather than 401,
// so this status is genuinely ambiguous; the error must name BOTH causes and the env var
// that fixes the actionable one. With a token already set that hint would be wrong, so it
// must not appear.
func TestGitHubNamesTheTokenOnAnUnauthenticated404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
}))
defer srv.Close()
g := newTestGitHub(srv)
_, err := g.latestStable(context.Background(), "acme/private")
if err == nil {
t.Fatal("want an error on 404")
}
if !strings.Contains(err.Error(), tokenEnv) {
t.Errorf("unauthenticated 404 must name %s so an operator knows the fix; got: %v", tokenEnv, err)
}
g.token = "ghp_secret"
_, err = g.latestStable(context.Background(), "acme/private")
if err == nil {
t.Fatal("want an error on 404")
}
if strings.Contains(err.Error(), tokenEnv) {
t.Errorf("a 404 WITH a token set must not blame the missing token; got: %v", err)
}
}
+5 -5
View File
@@ -141,12 +141,12 @@ func TestRunnerWithRoutingSource(t *testing.T) {
}))
defer paperSrv.Close()
// GitHub fixtures keyed by repo (felis-api's coord is a placeholder slug). The
// handler also mirrors GitHub's real gate: a UA-less request is refused.
// GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a
// UA-less request is refused.
ghBodies := map[string]string{
"/repos/felis/felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
"/repos/MliroLirrorsIngenuity/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
}
ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
+6 -4
View File
@@ -47,10 +47,12 @@ type Spec struct {
// pin — there is no policy path by which Topology can propose changing it.
func Topology() []Spec {
return []Spec{
// Coord "felis/felis" is a placeholder for the operator's own release repo: the
// GitHub source now consumes it, so the routing/parse path is live, but it will
// not resolve against real GitHub until the operator's actual repo slug is set.
{Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"},
// Felis's own release repo, and the same slug deploy/bootstrap.sh clones from
// (FELIS_REPO_URL). It is PRIVATE today, which is why the github source carries an
// optional token: unauthenticated, this coord answers 404 — the status GitHub uses
// to hide a repo's existence — and felis-api is the one tracked component where
// that happens. k3s and cloudflared are public and need no credential.
{Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "MliroLirrorsIngenuity/Felis"},
{Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
{Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
{Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"},
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// allowed to auto-apply.
func TestTopologyEncodesPolicies(t *testing.T) {
want := map[string]Spec{
"felis-api": {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"},
"felis-api": {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "MliroLirrorsIngenuity/Felis"},
"k3s": {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"},
"cloudflared": {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},
"velocity": {Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"},