Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/releases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# The comparison between what data/releases.json records about published
# releases and what the release lists say today.
#
# It runs on a schedule rather than on a pull request, for the reason the pins,
# token and roster comparisons beside it do: what it reads is what somebody else
# has published, so its verdict moves when they publish rather than when this
# tree changes, and a merge blocked by that is a gate punishing the wrong
# change. The half that needs no network is the build, which reads the record on
# every run of the gate.
#
# The record it judges is unlike the copies those three compare. Those hold a
# file with an authority somewhere else and a difference is evidence about that
# file; this one is the answer itself, taken once and committed, so nothing
# published anywhere can be read to find out whether it has gone stale. Until
# this ran, nothing did: the file aged in silence while the landing page stated
# the day it was taken as though a reader could rely on it, and a repository
# publishing its first finished release went on being rendered as a shell.
#
# It reports and does not write. The verb this asks for has a mode that would
# rewrite the record, and a run that took it would destroy the difference that
# is the evidence for the change somebody makes, on the one file here where a
# machine could write the answer instead of reporting it. The last step is what
# holds it to that.
name: Release record

on:
schedule:
# Weekly, on the cadence the other comparisons against something published
# elsewhere use, so the whole set is looked at on one rhythm rather than on
# several nobody can hold in their head. A day later than the roster it
# reads its repository names from.
- cron: "51 5 * * 3"
workflow_dispatch:

# Deny everything at the top level; the job below grants the one scope it needs.
permissions: {}

jobs:
releases:
name: Compare the release record against what is published
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read # checkout only; nothing here writes
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install the toolchain go.mod pins
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

- name: Compare every recorded repository against its release list
# The token is what an anonymous caller does not have, and twelve
# repositories from a shared address exhaust the anonymous rate: a run
# taken that way answered 403 for the fifth repository it asked about.
# A run that could not ask says nothing about the record rather than
# reading it and finding it current, and the scope below is read-only.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: go run . releases check

- name: Prove the run wrote nothing
# After the comparison whether it passed or failed, because the verb
# this asks for can write that file and a run that rewrote it on its way
# to a red verdict is exactly the failure that would be missed.
if: always()
shell: bash
run: |
set -euo pipefail
git status --porcelain=v1
git diff --exit-code
test -z "$(git status --porcelain=v1)"
8 changes: 4 additions & 4 deletions data/releases.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"taken": "2026-08-26",
"taken": "2026-09-01",
"command": "go run . releases, which reads https://api.github.com/repos/OWNER/NAME/releases?per_page=100 for each repository the roster names, and the .meta.json asset of each finished release for the server generation it targets",
"repositories": {
"Flowfin/jellyfin-plugin-discover": {
Expand All @@ -15,7 +15,7 @@
"prereleases": 0
},
"Flowfin/jellyfin-plugin-requests": {
"finished": 1,
"finished": 2,
"prereleases": 0,
"generations": [
"10.11"
Expand All @@ -34,8 +34,8 @@
"prereleases": 0
},
"Flowfin/jellyfin-plugin-sso": {
"finished": 29,
"prereleases": 53,
"finished": 37,
"prereleases": 61,
"generations": [
"10.11"
],
Expand Down
34 changes: 34 additions & 0 deletions internal/releases/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"

Expand Down Expand Up @@ -53,6 +54,7 @@ func Fetch(repository string) (releases.Repository, error) {
return releases.Repository{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
authorise(req)

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
Expand Down Expand Up @@ -243,3 +245,35 @@ func escapeRepository(repository string) string {
}
return url.PathEscape(owner) + "/" + url.PathEscape(name)
}

// TokenVariable is the environment variable a token is read from. It is the
// name a workflow already puts a token under, so a run inside one asks with it
// by having it in the environment rather than by being told to.
const TokenVariable = "GITHUB_TOKEN"

// authorise puts a token on the request where the environment holds one.
//
// Without it the request is anonymous, and an anonymous caller is held to a
// rate the twelve repositories here exhaust: a scheduled run from a shared
// address answered 403 for the fifth repository it asked about on 2026-09-01,
// which is a run that says nothing about the record rather than one that read
// it and found it current.
//
// It is optional rather than required, because a contributor asking about
// twelve repositories once is inside the anonymous rate and should not have to
// hold a credential to run a verb. A token that is not there is not an error
// and the run says nothing about it: what it would report is the state of
// somebody's environment, and the refusal that matters is the one the request
// itself answers with.
//
// Only this request carries it. The metadata beside a release is fetched from
// wherever the release list says, which is not this interface and is not a host
// this repository chose, and a credential sent there is a credential handed to
// a third party.
func authorise(req *http.Request) {
token := strings.TrimSpace(os.Getenv(TokenVariable))
if token == "" {
return
}
req.Header.Set("Authorization", "Bearer "+token)
}
55 changes: 55 additions & 0 deletions internal/releases/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,58 @@ func TestMetadataThatCouldNotBeReadIsAFailureRatherThanTheThirdState(t *testing.
}
}
}

// The token reaches the request that asks this interface and nothing else. A
// credential put on the request for the metadata beside a release would be
// handed to whichever host that release names, which is not this interface and
// is not a host this repository chose.
func TestTheTokenReachesTheReleaseListAndNothingElse(t *testing.T) {
t.Setenv(TokenVariable, " a-token ")

req, err := http.NewRequest(http.MethodGet, API+"a/b/releases?per_page=100", nil)
if err != nil {
t.Fatalf("building the request: %v", err)
}
authorise(req)
if got := req.Header.Get("Authorization"); got != "Bearer a-token" {
t.Errorf("the request carries the authorisation %q", got)
}

// The metadata is reached through the getter below, and a case that
// sees a header on it is a case that has caught the leak.
asked := ""
_, _, err = generationsOf(func(address string) (*http.Response, error) {
asked = address
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(`{"targetAbi":"10.11.0.0"}`)),
}, nil
}, []release{{Tag: "1.0", Assets: []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}{{Name: "a" + metadataSuffix, URL: "https://elsewhere.example/a.meta.json"}}}})
if err != nil {
t.Fatalf("reading the generation: %v", err)
}
if asked != "https://elsewhere.example/a.meta.json" {
t.Errorf("the metadata was read from %q", asked)
}
}

// No token in the environment is not an error, and the request goes anonymous
// rather than carrying an empty credential. A contributor asking about twelve
// repositories once is inside the anonymous rate and should not have to hold
// one.
func TestNoTokenLeavesTheRequestAnonymous(t *testing.T) {
t.Setenv(TokenVariable, " ")

req, err := http.NewRequest(http.MethodGet, API+"a/b/releases?per_page=100", nil)
if err != nil {
t.Fatalf("building the request: %v", err)
}
authorise(req)
if _, carried := req.Header["Authorization"]; carried {
t.Errorf("the request carries an authorisation header with no token behind it: %q", req.Header.Get("Authorization"))
}
}
71 changes: 59 additions & 12 deletions internal/releases/releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,36 +231,83 @@ func Run(root string, repositories []string, taken, command string, fetch Fetche
return fmt.Errorf("writing %s: %w", File, err)
}

moved := report(before, rec, out)

fmt.Fprintf(out, "releases: %d repository(s) recorded as taken on %s, %d entry(s) moved, written to %s\n",
len(rec.Repositories), rec.Taken, moved, File)
return nil
}

// Check asks the same question Run asks and writes nothing, refusing while the
// record no longer says what the release lists say.
//
// It is the route a schedule takes. Nothing else re-takes the record, so
// without one the file ages in silence while the landing page states the day it
// was taken as though a reader could rely on it, and a repository that publishes
// its first finished release goes on being rendered as a shell.
//
// It refuses on a record it could not read, which is the opposite of what Run
// does with the same failure and is the same reason in both directions. Run is
// about to replace the file, so an unreadable one is a state it may report and
// carry on from; this one has nothing to compare against, and a run that
// reported no difference because it read no record would be the green mark over
// something that did not happen.
func Check(root string, repositories []string, fetch Fetcher, out io.Writer) error {
before, err := Load(root)
if err != nil {
return fmt.Errorf("releases: %w", err)
}

// The moment and the command are carried over from the record rather
// than taken again, because what this compares is the counts and a
// fresh date on one side would be a difference in every run.
now, err := Refresh(repositories, before.Taken, before.Command, fetch)
if err != nil {
return fmt.Errorf("releases: %w", err)
}

moved := report(before, now, out)

fmt.Fprintf(out, "releases: %d repository(s) compared against %s, taken on %s, %d entry(s) moved, nothing written\n",
len(now.Repositories), File, before.Taken, moved)
if moved > 0 {
return fmt.Errorf("releases: %d entry(s) in %s no longer say what the release lists say, and the pages render that file; re-take it with `go run . releases`", moved, File)
}
return nil
}

// report names every repository the two records disagree about and answers with
// how many they were. It is shared by the two verbs above so that a schedule and
// a refresh describe the same difference in the same words, rather than a reader
// meeting one vocabulary in a red run and another in the change that repairs it.
func report(before, now Record, out io.Writer) int {
moved := 0
names := make([]string, 0, len(rec.Repositories))
for n := range rec.Repositories {
names := make([]string, 0, len(now.Repositories))
for n := range now.Repositories {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
now := rec.Repositories[n]
current := now.Repositories[n]
was, had := before.Repositories[n]
switch {
case !had:
moved++
fmt.Fprintf(out, " %s: NEW, %s\n", n, said(now))
case !same(was, now):
fmt.Fprintf(out, " %s: NEW, %s\n", n, said(current))
case !same(was, current):
moved++
fmt.Fprintf(out, " %s: MOVED, was %s, now %s\n", n, said(was), said(now))
fmt.Fprintf(out, " %s: MOVED, was %s, now %s\n", n, said(was), said(current))
default:
fmt.Fprintf(out, " %s: unchanged, %s\n", n, said(now))
fmt.Fprintf(out, " %s: unchanged, %s\n", n, said(current))
}
}
for n := range before.Repositories {
if _, still := rec.Repositories[n]; !still {
if _, still := now.Repositories[n]; !still {
moved++
fmt.Fprintf(out, " %s: GONE, the roster no longer names it and this run did not ask about it\n", n)
}
}

fmt.Fprintf(out, "releases: %d repository(s) recorded as taken on %s, %d entry(s) moved, written to %s\n",
len(rec.Repositories), rec.Taken, moved, File)
return nil
return moved
}

// said is one repository's entry in the words the run reports it in. The
Expand Down
Loading
Loading