/server-upgrade — investigate a Jellyfin release + file the issues that matter
SkillDev toolsTriage a Jellyfin server release against JellyRock's API usage by editing the ONE per-version release-triage digest issue (auto-opened by CI) with verdicts and filing per-finding sub-issues for the changes worth standalone tracking. Consumes the Phase-2 data report (`.api-watch/cache/findings-candidates-<from>..<to>.json` from `npm run api-watch:findings`), investigates each candidate that needs investigation by reading the cited app-usage sites, resolves the edge cases the design doc lists (spec-contract break vs runtime break, capability-guarded fallbacks, spec-regeneration artifacts, enum switches, graceful degradation, opportunity-worth-it, coverage symmetry), and emits a verdict per finding. The mechanical filer (`scripts/server-upgrade.js`, plan/execute split mirroring `/crash-report`) then dedups against existing `[server-upgrade]` issues, files `file` verdicts as native GitHub SUB-ISSUES of the digest (`gh issue create / comment / reopen` with findingKey dedup), inline-notes the skip/monitor ones, rewrites the digest with the verdict checklist, and closes it when fully triaged. Human-gated: running execute is one batch approval per release (the graduated trust ratchet) — nothing auto-files. The recurring floor findings (post-floor endpoints like MediaSegments/Lyrics/QuickConnect) self-resolve via the committed endpoint-availability registry, so they don't reappear every release. Use when a new Jellyfin stable release lands and you want to know what (if anything) it breaks for us. Prerequisite: the report exists (run `api-watch:findings <acknowledged> <latest>` first, committing the latest version's fingerprint via `spec-fingerprint.js <latest>` if needed). RCs and unstable/master builds have a proactive LOCAL triage path (ephemeral fetch, findings written to a `.claude/handoffs/` note, re-diffable as the pre-release evolves) but still never file GitHub issues — durable filing happens via this same skill on the eventual stable release.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /server-upgrade — investigate a Jellyfin release + file the issues that matter skill
What this skill tells your AI
The instructions your AI receives, as published by jellyrock/jellyrock in .claude/skills/server-upgrade/SKILL.md and read by ahel’s review.
Phase 3 of the server-upgrade-automation pipeline (docs/architecture/server-upgrade-automation.md). This skill is the judgment half (pipeline stage 4, "Investigate") wrapped around the mechanical half (stage 5, "File" — scripts/server-upgrade.js). The Phase-2 report already did the deterministic work: it lists every spec change that intersects code JellyRock ships, with full provenance. This skill reads each candidate's cited code, decides whether it's a real problem and what to do, then hands a per-finding verdict to the filer, which dedups + labels + writes the issues behind one batch approval.
The load-bearing seam: the script never decides "is this a real problem"; the agent never touches GitHub. The script owns finding identity (so dedup is stable across releases) and issue mechanics; the agent owns the per-finding call. That's what makes the pipeline both trustworthy and low-maintenance.
Like /crash-report, this skill has no sibling INVESTIGATION.md — each filed issue IS the team-shared handoff, and per-finding deep-dive is offloaded to /issue-triage <N>. The run-summary in .claude/handoffs/ is a local audit log / resume aid for the person running the triage.
Inputs
$ARGUMENTS: optionally the path to a Phase-2 report (.api-watch/cache/findings-candidates-<from>..<to>.json), OR an explicit <from> <to> version pair. If omitted, resolve it in Step 0 from the signals-backlog jellyfin-server-stable row (latest_acknowledged → latest_upstream).
<to> selects the channel and the path through this skill:
- A stable version (
10.11.10) → the full pipeline below (investigate → file GitHub issues against the per-version digest). - An RC (
10.12.0-rc1), the literalunstable/master, or an explicit datestamp (20240402201942) → the pre-release path (Step 0a): investigate locally, write a handoff, file no GitHub issues. This is the proactive surface — react to an upcoming release (or master) before it ships, and re-diff as it changes.
Step 0 — Preflight
-
Locate (or produce) the data report. If
$ARGUMENTSnames a report file, use it. Otherwise readdocs/signals-backlog.md'sjellyfin-server-stablerow forlatest_acknowledged(the<from>) andlatest_upstream(the<to>), and check for.api-watch/cache/findings-candidates-<from>..<to>.json. If it's missing, generate it — committing the<to>fingerprint first if needed (the forward anchor is committed fingerprints, per the Phase-2 decision):ls docs/architecture/spec-fingerprints/jellyfin-<to>.json 2>/dev/null \ || node scripts/generate/spec-fingerprint.js <to> # fetches + writes the fingerprint npm run api-watch:findings <from> <to> # writes the gitignored reportspec-fingerprint.js <to>hits the network (Jellyfin's permanent OpenAPI archive) — surface that to the user before running it. This committed-fingerprint flow is stable-only: if<to>is an RC,unstable/master, or a datestamp, do NOT commit a fingerprint — jump to Step 0a (the pre-release path), which fetches ephemerally and never writes a committed anchor.To just preview a release without committing its fingerprint (e.g. a quick "what would this flag?" before a full triage),
node scripts/generate/findings-candidates.js <from> <to> --fetch --stdoutbuilds the<to>fingerprint in-memory and prints the full report. The real triage still commits the<to>fingerprint (the reviewed anchor) per the step above. -
The labels exist. Check
gh label list --search server-upgrade. Ensure all three (one-time per repo;bug/enhancementare GitHub defaults — don't create them):gh label create server-upgrade --color 1d76db --description "Filed automatically by /server-upgrade from a Jellyfin release API diff"(per-finding sub-issues)gh label create server-upgrade:tracker --color 1D76DB --description "Per-version Jellyfin release-triage digest"(the digest)gh label create server-upgrade:triaging --color FBCA04 --description "A server-upgrade digest /server-upgrade has triaged — CI hands off the body"
-
Locate the per-version digest (Phase 6). CI auto-opens ONE digest issue per server version (
[server-upgrade] Jellyfin <to> — release triage, labelserver-upgrade:tracker). Find it:gh issue list --label server-upgrade:tracker --state all --json number,title,state --limit 100and match the<to>version in the title. Capture its number ($DIGEST) for Step 5 —execute --digest $DIGESTrewrites it with verdicts, files sub-issues under it, and (when fully triaged) closes it. If no digest exists (you're triaging ahead of CI), either open one first (gh issue create --title "[server-upgrade] Jellyfin <to> — release triage" --label server-upgrade:tracker --body "Triaged manually ahead of CI.") or run without--digest(promotions file standalone — acceptable, but the digest is the preferred hub). -
Read the report's counts (
countsblock) so you can tell the user up front: N candidates needing investigation (broken down asbreaking/coverage-gap/symmetry-advisory/opportunity), plus how many werefloorKnown(post-floor endpoints resolved by the endpoint-availability registry — not investigated), suppressed, or frozen-skipped (respectsuppressed: trueandneedsInvestigation: false). If a coverage-gap or symmetry candidate that you'd expect to be floor-known instead showsneedsInvestigation: true, it's an UNREGISTERED post-floor endpoint — investigate it, and if it's a known-handled case, the fix is to add an entry todocs/dev/jellyfin-endpoint-availability.yml(mention this to the user; thelint:endpoint-availabilitycheck validates the entry's guard/sibling claim).
Step 0a — Pre-release path (RC / unstable / master)
Fires when <to> is an RC (-rcN/-betaN/-alphaN suffix), the literal unstable/master, or a datestamp (20240402201942). This is the proactive surface: triage an upcoming release (or master) before it ships, then re-diff as it changes. It investigates locally and files NO GitHub issues — the durable filing is the normal stable flow when the final lands. (Background + rationale: docs/architecture/server-upgrade-automation.md → "Pre-release channels".)
You arrived here from Step 0.1 — skip the rest of Step 0 (0.2 label creation, 0.3 digest location, 0.4 counts-with-digest are all GitHub-bound; there is no digest for a pre-release). Run this self-contained sequence instead:
-
Resolve
<from>/<to>(explicit<from> <to>args always override):- RC: read the
docs/signals-backlog.mdjellyfin-server-rcrow —latest_acknowledgedis the<from>,latest_upstreamis the<to>. The re-diff story: after triagingrc1you set that row'slatest_acknowledged = <base>-rc1(Step 6); whenrc2lands, this diffsrc1 → rc2, surfacing only the delta since your proactive work. - Unstable / master: pass the literal
unstable(ormaster) as<to>—findings-candidates.jsresolves it to the latest immutable datestamped build and printsresolved unstable → <datestamp>to stderr (and into the report'stoVersion). Relay that pinned datestamp to the user — it makes the run reproducible and becomes the next<from>for a master-over-master re-diff.<from>defaults to the latest acknowledged stable, or an explicit prior datestamp. The mutablejellyfin-openapi-unstable.jsonroot pointer is never pinned.
- RC: read the
-
Generate the report ephemerally — no committed fingerprint (these are throwaway anchors; the archive is permanent, so
--fetchrebuilds them in-memory). Do NOT use--stdout— let the script resolve<to>and name the cache file itself:node scripts/generate/findings-candidates.js <from> <to> --fetch # <to> may be a version, an RC, or the literal `unstable` / `master`.For
unstable/masterthe script resolves the datestamp and writes.api-watch/cache/findings-candidates-<from>..<datestamp>.json, printing that exact path + aresolved unstable → <datestamp>line. Use that printed path as<report-path>in Step 1, and relay the datestamp to the user (it's the reproducible pin + the next<from>for a re-diff).--stdoutwould force you to hand-name the file and would mis-name it..unstable.json. Surface that this hits the network for any spec not already in.api-watch/cache/. -
Investigate — run Steps 1–2 below exactly as written (scaffold + per-candidate judgment), passing the printed cache path as
<report-path>; they're version-agnostic. -
Then SKIP Steps 3–5. Instead of the GitHub plan/execute, write a local handoff to
.claude/handoffs/server-upgrade-prerelease-<resolved-to>-<timestamp>.md— use the report'stoVersion(the datestamp for unstable), not the literalunstabletoken, so the handoff is reproducible + sorts by build. Capture: the<from> → <resolved-to>window, the per-finding verdicts + rationale, and a "to fix proactively" list (thefile-worthy findings, as fix targets — not GitHub issues). For anunstable/master(next-major) run, lead that list with theneedsInvestigationbreaking findings — these are the worklist a new>= Ntier would absorb; if any warrants a new tier, point the user at/new-api-version. Make zeroghcalls (this path also skips Step 0's label/digestghsteps — there is no digest).On this path, a
fileverdict does not need a fulldraftIssueBodyin the verdict file — that file is a throwaway temp you'llrmat the end, so a long issue body there is wasted. Put the fix direction directly in this handoff's worklist (what changed, the cited sites, the rename-vs-removal resolution, the suggested fix). A short verdictrationaleis still required for the audit trail. -
Close the loop (Step 6 applies): for RC, offer to
/log signal jellyfin-server-rcto setlatest_acknowledged = <to>so the next re-diff baselines correctly. For unstable, the pinned datestamp lives in the handoff (master moves too fast for a daily acknowledged cursor). Then stop — the pre-release path ends here.
Step 1 — Scaffold the verdict template
SCAFFOLD=$(mktemp --suffix=.json /tmp/server-upgrade-scaffold.XXXXXX)
VERDICTS="${SCAFFOLD%.json}.verdicts.json" # a NEW path you'll Write the filled verdicts to (Step 2)
node scripts/server-upgrade.js scaffold --report <report-path> --out "$SCAFFOLD"
mktemp --suffix=.json <template>requires the template to end inX— put the.jsonin--suffix, not after theX's (a...XXXXXX.jsontemplate errors out with an empty$SCAFFOLD).
This is a pure read: it selects the candidates the report flagged needsInvestigation, derives each one's stable findingKey (you must NOT change these), and pre-lists the appUsage.sites you'll read plus the mechanical severityGuess and base labels. No GitHub, no writes. If the scaffold has zero verdicts, report "nothing JellyRock uses changed in <from> → <to>" and stop (this is the common, good outcome — the pipeline's whole point is staying silent about churn that doesn't touch us).
Step 2 — Investigate each candidate (the judgment core)
Load $SCAFFOLD with the Read tool (not cat). For every verdict entry, open each path in appUsage.sites (and grep nearby for dispatch branches / capability guards) and decide. This is the part that can't be a script. Resolve the design doc's edge cases:
Citation-depth floor — cite the exact file:line, not just the file. Every usage / non-usage claim in a rationale must pin the precise call-site line (the line where the changed surface is sent, or — for a "we never send X" skip — the line that builds the request and demonstrably omits it). A rationale that says "JellyRock never sends X" or "the read is guarded" without the file:line is unfinished, exactly like a "confirm whether renamed or dropped" draftIssueBody (#8). This floor is deterministic and network-free; it exists because investigation depth is otherwise luck-of-the-draw across runs — two runs on byte-identical input can reach the same correct verdicts with different evidence, and a re-run can silently regress (e.g. citing source/api/items.bs generally instead of the :457 line that proves the audio direct-stream call omits the removed param). Pin the line and the next run can't drift below you. (The FROM-version read in #8 already covers removed-surface baselines; surfacing the upstream PR that motivated a change is welcome enrichment but is not part of the floor — don't turn it into a mandated rate-limited gh search hunt.)
-
Spec-contract break ≠ runtime break. A retype (
int64 → int32) or a nullable change is usually a no-op given JellyRock's?? defaultpattern + BrightScript's dynamic typing — but sometimes a silent break (e.g. a value that overflows anInteger, or a field the app indexes into assuming an array). Read the site and decide which. -
Used-with-a-fallback. An endpoint "removed" upstream but covered for the affected tier by a dispatch branch (
if m.getApiVersion() >= N) or a capability guard (e.g.supportsMediaSegments()) is not breaking —skipit with that rationale. This is also how you disposition the expectedcoverage-gapcandidates the doc warns about: an endpoint tagged[1, ∞)only because it's guarded by a capability check rather than a version branch (e.g./audio/{}/lyrics) is fine on the floor —skipwith the guard cited. -
Spec-regeneration artifacts. If the change looks cosmetic (a schema refactor, a
$refreshuffle) with no real contract delta,skipit — and consider whether it belongs in.api-watch/suppressions.ymlso future runs drop it mechanically (mention this to the user; don't edit the YAML silently). -
Enum changes. A value added/removed on an enum the app switches on (
BaseItemKind, streamType,MediaType). Read the switch sites — an added value the app falls through on gracefully is low/skip; a removed value the app still sends, or relies on receiving, is real. -
Graceful degradation vs genuine break when a field disappears: does the feature still work degraded, or does a screen break?
-
Opportunity worth it? For
opportunity(new endpoint) candidates, decide whether it maps to a real JellyRock feature gap worth anenhancementissue (file) or is noise for us (skip), or worth tracking but not now (monitor). -
Coverage symmetry. A
symmetry-advisoryflags a modern-only endpoint (wired tier ≥N) whose operation the floor server also serves — so the app might be leaving floor users without it. Disposition the same way as #2, but looking for a lower-tier dispatch sibling: grep the cited site (and nearby) for anif m.getApiVersion() >= Nbranch that calls a different endpoint for the floor (e.g./Items/{}on V2 vs/Users/{}/Items/{}on V1). If a sibling covers the floor →skipwith that rationale — this is the expectedGET /itemscase (the V1 branch uses/Users/{userId}/Items), the symmetry analogue of #2's guarded-fallback. If no floor path exists for the operation → floor users genuinely lack it →file(a real coverage gap,enhancement). Cosmetic / not worth it →monitor. -
Removal vs rename — resolve it, don't hedge. A
param-removed/field-removedfinding is the spec diff's view, and the diff cannot tell a genuine removal from a rename/move. The report does the same-scope half for you: eachparam-removed/field-removedcarriesrenameCandidates(in the scaffold too) — same-operation/same-schema additions that could be the rename, same-signature first.-
Empty
renameCandidates: []→ nothing in the same scope could be the rename, so it's a genuine removal (not a rename/move). But genuine-removal ≠ behavior-change: the diff compares spec shape, not whether the FROM version actually honored the param/field at runtime. A param can sit in the OpenAPI spec while being a dead no-op (C#[ParameterObsolete], or simply never wired into the query object / handler) — the diff still reports it "removed," but deleting already-dead published surface changes nothing. Anti-pattern: concluding "emptyrenameCandidates→ upstream behavior changed → replicate client-side" without checking the baseline. Before that conclusion, do one cheap FROM-version check — read the FROM-tag controller/model (gh api repos/jellyfin/jellyfin/contents/<path>?ref=v<from>) and confirm the param/field was actually used (wired into the query object / read in the handler). If it was a no-op in FROM, the removal is pure cleanup →skip, no client-side fix. If it was honored in FROM and gone in TO, the behavior genuinely changed → replicate client-side or degrade gracefully. (Worked example — the trap:disableFirstEpisodeon/Shows/NextUplooks like a textbook genuine removal, but inv10.11.10it was already[ParameterObsolete]and unwired intoNextUpQuery, so master merely deletes a dead param →skip, not replicate.) -
Populated
renameCandidates→ a likely rename/move: confirm which candidate (and that semantics match), then map the new name behind version dispatch (the old behavior is preserved upstream). For a high-severityfile, confirm against upstream source — the spec gives you the hook:operationIdIS the C# controller-method name, and a schema name IS its model class:# endpoint param: resolve the controller method, see its CURRENT params gh api "search/code?q=<operationId>+repo:jellyfin/jellyfin" --jq '.items[].path' gh api "repos/jellyfin/jellyfin/contents/<path>?ref=master" --jq '.content' | base64 -d | grep -ni "<oldParam>\|<candidateParam>" # schema field: resolve the model class gh api "search/code?q=class+<SchemaName>+repo:jellyfin/jellyfin" --jq '.items[].path'Caveats: GitHub code search is auth-gated + rate-limited (~10/min) and can return several hits — disambiguate by path (
Jellyfin.Api/Controllers/…,MediaBrowser.Model/…); pin?ref=master(the master build the diff used). A cross-scope rename (a param moved to a different endpoint) won't show inrenameCandidates— that's exactly when this upstream read earns its keep.
A verdict whose
draftIssueBodystill says "confirm whether renamed or dropped" is unfinished —renameCandidatesplus, at most, onegh apiread resolves it now. -
For each, set in place (do not change findingKey):
real(bool),severity(high|medium|low),recommendedAction:file— a real problem (or worthwhile opportunity) worth an issue. Requiresseverity+ adraftIssueBody(GitHub markdown: what changed, why it affects us, the cited sites, suggested fix direction). The filer wraps it with a provenance header + footer, so write only the substance.skip— investigated, not a real problem (false positive, no runtime impact, guarded fallback, regeneration artifact). No body needed. A one-paragraphrationaleis required — it's the audit trail for why we looked and passed.monitor— real but deferred (opportunity for later, symmetry advisory not worth filing yet).rationalerequired; surfaced for a possible/log signal.
- Append any extra
labels(e.g.regression) beyond the prefilled base set.
Write your filled verdicts to the new $VERDICTS path with the Write tool — not back over $SCAFFOLD. Writing a fresh file sidesteps the Write tool's read-before-overwrite guard (overwriting $SCAFFOLD fails with "File has not been read yet" unless you loaded it with the Read tool first). Copy every entry from the scaffold into $VERDICTS, filled in. Every entry must end with a non-null recommendedAction — the filer flags any you leave blank as missing-verdict rather than dropping it.
Step 3 — Plan (mechanical; no GitHub writes)
Stable path only. If you arrived here via the pre-release path (Step 0a), stop — Steps 3–5 file GitHub issues, which pre-release runs never do. You should already have written the local handoff (Step 0a.4) and closed the loop (Step 0a.5).
PLAN=$(mktemp --suffix=.json /tmp/server-upgrade-plan.XXXXXX)
node scripts/server-upgrade.js plan --report <report-path> --verdicts "$VERDICTS" --plan-out "$PLAN"
The filer joins your verdicts back to the report by findingKey, dedup-searches GitHub (reads only) for an existing [server-upgrade] issue carrying each finding's version-independent identity, and reconciles into concrete actions: create (no existing issue), comment (recurrence on an open issue — same finding, new release), reopen (regression on a closed issue), plus the non-writing skip / monitor / missing-verdict / invalid-verdict. In Phase 6 the file actions (create/comment/reopen) become per-finding promotions filed as native GitHub sub-issues of the per-version digest; skip / monitor become inline checked-off notes on the digest.
Step 4 — Render the plan + confirm
Read $PLAN. Render it as:
-
Header: the digest issue (
#$DIGEST) being triaged;<from>→<to>(floor<floor>); investigation-candidate count;floorKnown+ suppressed/frozen-skip counts fromreportCounts(deliberately not investigated). -
Promotion table — one row per write action (each becomes a sub-issue of the digest):
# Action Existing Severity Finding Labels 1 create — high endpoint removed: GET /Items/{itemId} server-upgrade, bug 2 comment #812 (OPEN) medium field retyped: BaseItemDto.runTimeTicks server-upgrade, bug 3 reopen #790 (CLOSED) high floor coverage gap: GET /UserViews server-upgrade, bug -
Investigated, not filing (inline notes on the digest) — the
skiprows with their rationale (the audit trail), andmonitorrows. -
⚠️ Needs attention — any
missing-verdict(you skipped investigating a candidate — go back to Step 2) orinvalid-verdict(malformed verdict; theproblemssay why). Resolve these before executing, or the findings won't be filed.
Then ask via AskUserQuestion: (a) execute the plan as-is, (b) revise verdicts and re-plan (loop to Step 2/3), or (c) abort. This confirmation IS the graduated-trust-ratchet gate — one batch approval per release, not per finding. Nothing files without it. (If a finding-class has graduated — its type is in AUTO_FILE_CLASSES, so its actions carry autoFileEligible: true — that class's create actions are pre-approved within this batch and don't need separate confirmation. Today AUTO_FILE_CLASSES is empty: every class is still gated. Graduation only ever relaxes this in-session approval; the CI tracker never auto-files. See the design doc's "Graduation procedure".)
Step 5 — Execute
node scripts/server-upgrade.js execute --plan "$PLAN" --digest "$DIGEST" --close-digest
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 41
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
server-upgrade- Source
- github.com/jellyrock/jellyrock