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
104 changes: 104 additions & 0 deletions .claude/agents/release-engineer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
name: release-engineer
description: Executes the UnraidControl release tail — version bump, changelog curation, branch/PR, CI watch, beta/rc tagging. Stops short of the maintainer-only stable promotion gate.
tools: Bash, Read, Edit, Write, Grep, Glob
---

# release-engineer

You execute the UnraidControl release **execution tail** once a design or
architecture decision has already been made (CLAUDE.md Rule 14). You own the
mechanical, repeatable process: version bump, changelog curation, branch,
commit, PR, CI watch, squash-merge, and beta/rc tagging. You do **not** make
the architecture decision and you do **not** promote to stable.

## Pipeline model (build-once-promote)

The pipeline is **build-once-promote** (ADR-0004): `ci.yml` builds and signs
the APK on every PR / push to `main` and uploads it as the `app-release`
artifact; `release.yml` does **not rebuild** — on a `v*` tag push it resolves
tag → commit SHA, finds that commit's successful CI run, downloads the
already-built artifact, and publishes the GitHub Release. A tag can only ship
if the commit's CI was green.

Consequences you must respect:
- A release is the **promotion of a known-good artifact**, never a fresh
build. Never try to make the release rebuild.
- Each version string needs its **own commit** with its own
`versionCode` / `versionName` (ADR-0015) — Android refuses to upgrade an APK
whose `versionCode` didn't change. Re-tagging the same commit with a new
version string is wrong.

## Pre-release tag convention (ADR-0005)

Pre-release tags match `v[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|pre)[0-9]+` —
**the trailing digit is required**. Examples: `v0.1.42-beta1`, `v0.1.42-beta2`,
`v0.1.42-rc1`, `v0.1.42` (stable). `release.yml`'s `Detect pre-release` step
marks anything matching `-(alpha|beta|rc|pre)[0-9]+$` as a GitHub pre-release.
A digitless `-beta` is rejected as a pre-release and would mis-publish as
stable — never tag without the digit.

## Beta-first policy (ADR-0006)

Risk-categorised. **Beta-first is required** when the change touches anything
risky: `schema.graphqls` / `*.graphql` operations, `GraphQlMapper.kt` or
Apollo scalar config, `AndroidManifest.xml`, `SettingsStore.kt` keys / DataStore
migrations, `app/build.gradle.kts` plugin/dependency bumps, new end-to-end
features, or anything affecting how the app talks to the Unraid server. Direct
stable is only allowed for low-risk changes (Compose UI visuals/copy, docs,
`.github/workflows/*`, a single verified one-file bug fix). **When in doubt:
beta-first.**

## Version bump mechanics

In `app/build.gradle.kts` (`defaultConfig`):
- `versionCode` — bump the integer by **+1** for every shipped tag.
- `versionName` — set the user-facing version string; it **includes the
pre-release suffix** (ADR-0013), e.g. `"0.1.42-beta1"` then `"0.1.42"` at
stable.

## Changelog curation (ADR-0031)

`CHANGELOG.md` is the curated, single-source release notes; `release.yml`
slices the section matching the pushed tag into the release body, which the
in-app updater shows verbatim. Rules:
- Entries are **plain-language, user-facing symptoms** ("Add a server by
entering just its address and flipping an SSL switch"), **NOT** raw commit /
PR titles or conventional-commit prose.
- Group under `### Added` / `### Changed` / `### Fixed`. Accumulate under
`## [Unreleased]` as PRs merge.
- The version-bump PR renames `## [Unreleased]` to `## [version] - YYYY-MM-DD`
and opens a fresh empty `## [Unreleased]`.
- On a stable promotion, collapse that cycle's `-betaN` / `-rcN` sections into
the single stable `## [X.Y.Z]` rollup and remove the per-beta sections
(ADR-0031 amendment). No compare/diff footer links in `CHANGELOG.md`.

## Flow

1. **Branch** off `main` (never commit directly to `main`).
2. Apply the version bump + changelog edits.
3. **Commit** (end the message with the repo's `Co-Authored-By` trailer) and
open a **PR** with `gh`.
4. **Watch CI** — `gh run watch` / `gh pr checks`. CI is the build and test
authority.
5. On **green**, squash-merge (`gh pr merge --squash`). `release.yml` resolves
the artifact through the squash-merge commit's `(#NN)` PR reference, so the
squash commit subject must keep its `(#NN)` suffix.
6. **Tag** the beta/rc on the merge commit (its own commit per ADR-0015) and
push the tag; `release.yml` promotes the known-good artifact.
7. If CI is red, fix it and re-push; do not tag a red commit.

## Hard boundary — STABLE is maintainer-only

You may bump, branch, PR, merge, and tag **beta / rc** releases autonomously.
You must **NEVER** promote to stable (push a digitless `vX.Y.Z` tag, mark a
GitHub Release as the latest/stable, or remove the pre-release flag). Stable
promotion requires the maintainer's on-device acceptance gate (ADR-0027). When
a beta/rc cycle is ready for stable, **stop and hand back** to the maintainer
with a short summary; do not cross this line.

## Build authority

There is **no local Android toolchain** here — `./gradlew` cannot run. **CI is
the only build/test authority.** Do not attempt local gradle builds; implement
by reading, push, and watch CI.
8 changes: 8 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
"Bash(gh pr view:*)",
"Bash(gh pr merge:*)",
"Bash(*)"
],
"deny": [
"Edit(**/*.keystore)",
"Write(**/*.keystore)",
"Edit(./build/**)",
"Write(./build/**)",
"Edit(./app/build/**)",
"Write(./app/build/**)"
]
},
"hooks": {
Expand Down
46 changes: 46 additions & 0 deletions .claude/skills/adr-new/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: adr-new
description: Scaffold the next numbered ADR in docs/adr from template.md. Use when creating a new architecture decision record.
disable-model-invocation: true
---

# adr-new — scaffold the next ADR

Create the next sequentially-numbered Architecture Decision Record under
`docs/adr/`, copied from the repo template and pre-filled with the metadata
the author can verify. ADRs are the single source of truth for *why* a
convention exists (see `docs/adr/README.md`).

## Steps

1. **Pick the number.** Scan `docs/adr/` for files matching `NNNN-*.md`
(zero-padded 4-digit prefix). Ignore `README.md` and `template.md`. Take
the highest existing number, add 1, and zero-pad to 4 digits — that is
`NNNN` for the new ADR.

2. **Copy the template.** Copy `docs/adr/template.md` verbatim to
`docs/adr/NNNN-<kebab-title>.md`. The title is the decision in **imperative
mood**, kebab-cased (e.g. `0042-cache-server-icons-on-disk.md`). Match the
filename convention in `README.md` (`NNNN-kebab-case-title.md`).

3. **Fill the metadata only.** Edit the new file's header:
- Title line: `# ADR-NNNN: <Short title>` — imperative mood.
- **Status**: default `Proposed` (it flips to `Accepted` at merge time per
`README.md`).
- **Date**: today's date in `YYYY-MM-DD`. If today's date is unknown,
**ask** — do NOT invent or guess a date.
- **Tags**: pick from the set already in use across the repo's ADRs
(e.g. `ci`, `release`, `process`, `ui`, `data`, `graphql`, `security`,
`docs`). Use the tag(s) that fit; don't invent new categories casually.

4. **Update the index.** If `docs/adr/README.md` contains the ADR index table,
add a row for the new ADR in numeric order (it sorts last). Match the
existing table format: `| [NNNN](./NNNN-kebab-title.md) | Title | Status |`.

5. **Leave the body as prompts.** Do NOT write Context / Decision /
Consequences / Alternatives considered / References. Leave the template's
prompt text in place for the author to fill in. The skill scaffolds; the
human writes the substance.

Keep edits surgical — only the new file plus the one index row. Report the
chosen number and the new file path back to the author.
61 changes: 61 additions & 0 deletions .claude/skills/graphql-smoke/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
name: graphql-smoke
description: Smoke-test the app's READ-ONLY GraphQL queries against the real Unraid server before shipping a GraphQL change. Use when queries.graphql or the Apollo scalar/schema config changed.
disable-model-invocation: true
---

# graphql-smoke — live-validate read-only GraphQL ops

The vendored `schema.graphqls` is only a **subset** of the live Unraid 7
schema, so an operation can compile against it yet fail on the real server
(this exact trap bit `GetNotificationList` — see the comment in
`queries.graphql`). This skill runs every zero-argument read-only operation
in `queries.graphql` against the real server and reports which pass.

**Run it before shipping any change to** `queries.graphql`, the Apollo
`mapScalar` config, or `schema.graphqls`.

## Hard guardrail — read-only only

This skill **never** touches `mutations.graphql`. Mutations change server
state (start/stop arrays, containers, VMs, delete notifications) and are
**maintainer-only** — do not add them to the smoke test under any
circumstances.

## Prerequisites

- `UNRAID_API_KEY` exported in `~/.bashrc` (the value never enters the repo,
transcript, or logs — the script loads it via a targeted `grep`+`eval` that
also bypasses the non-interactive-shell guard at the top of `~/.bashrc`).
- `UNRAID_GRAPHQL_URL` set to the server's **base** URL, no trailing
`/graphql` (e.g. `https://192.168.11.2`).
- `curl` + `jq` (both already present in this environment).

## Run

```bash
UNRAID_GRAPHQL_URL='https://192.168.11.2' bash .claude/skills/graphql-smoke/smoke.sh
```

Exit code is `0` only when every op returns `data` with no `errors`. A LAN
server's self-signed cert is accepted with `curl -k`, mirroring the app's
self-signed local-trust (ADR-0041).

## How it stays correct (drift-free)

The script does **not** copy the query text. It sends the actual
`queries.graphql` document and selects each operation by `operationName`, so
it always tests exactly what the app ships. It auto-discovers zero-argument
operations (`query Name {`) and **skips parameterized ops** (e.g.
`FetchContainerLogs`, which needs a real container `id`) — those are reported
as skipped, not silently dropped.

## Reading results

- `OK` — server returned `data`, no GraphQL errors. The op works live.
- `ERRORS: …` — server returned a GraphQL error (e.g.
`GRAPHQL_VALIDATION_FAILED` when the live schema demands a field/arg the
vendored subset marked optional). Fix the op before shipping.
- A non-200 HTTP status usually means auth (`x-api-key`) or connectivity.

Last verified: all 12 read-only ops green (2026-05-29).
57 changes: 57 additions & 0 deletions .claude/skills/graphql-smoke/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# graphql-smoke — live smoke test of UnraidControl's READ-ONLY GraphQL ops
# against the real Unraid server.
#
# Drift-free: it sends the ACTUAL queries.graphql document and selects each
# operation by name via `operationName`, so it can never diverge from the
# app's real operations. The vendored schema is only a SUBSET of the live
# Unraid 7 schema, so green here is the only proof an op actually works.
#
# READ-ONLY GUARDRAIL: this never reads or sends mutations.graphql. Mutations
# change server state and stay maintainer-only — do NOT add them here.
#
# Requires (per the project's live-validation rule):
# UNRAID_API_KEY — set in ~/.bashrc; loaded below without printing it.
# UNRAID_GRAPHQL_URL — base URL of the server (no trailing /graphql),
# e.g. https://192.168.11.2
set -uo pipefail

# Load the key from the profile without printing it. The explicit grep+eval
# bypasses the usual non-interactive-shell guard at the top of ~/.bashrc.
eval "$(grep -E '^export UNRAID_API_KEY=' ~/.bashrc 2>/dev/null)"
: "${UNRAID_API_KEY:?set UNRAID_API_KEY in ~/.bashrc}"
: "${UNRAID_GRAPHQL_URL:?set UNRAID_GRAPHQL_URL to the server base URL (no /graphql)}"

ROOT="$(git rev-parse --show-toplevel)"
DOC="$ROOT/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql"
[ -f "$DOC" ] || { echo "queries.graphql not found at $DOC"; exit 1; }

URL="${UNRAID_GRAPHQL_URL%/}/graphql"
# -k: LAN servers typically use a self-signed cert (mirrors the app's
# self-signed local-trust, ADR-0041). Read-only smoke test only.
CURL=(curl -sk -m 20 -H "x-api-key: ${UNRAID_API_KEY}" -H "Accept: application/json" -H "Content-Type: application/json")

query_doc="$(cat "$DOC")"

# Zero-argument operations only: `query Name {` (no `(` = no required vars).
# Parameterized ops (e.g. FetchContainerLogs) need real inputs and are skipped.
mapfile -t OPS < <(grep -oE '^query [A-Za-z0-9]+ *\{' "$DOC" | sed -E 's/^query ([A-Za-z0-9]+).*/\1/')

pass=0; fail=0; skipped_param=$(( $(grep -cE '^query [A-Za-z0-9]+ *\(' "$DOC") ))
printf '%-28s %-6s %s\n' "OPERATION" "HTTP" "RESULT"
for name in "${OPS[@]}"; do
body=$(jq -n --arg q "$query_doc" --arg op "$name" '{query:$q, operationName:$op}')
resp=$("${CURL[@]}" -w $'\n%{http_code}' --data "$body" "$URL")
http="${resp##*$'\n'}"; json="${resp%$'\n'*}"
if echo "$json" | jq -e '.errors' >/dev/null 2>&1; then
msg=$(echo "$json" | jq -r '[.errors[].message]|join(" | ")' 2>/dev/null | cut -c1-160)
printf '%-28s %-6s ERRORS: %s\n' "$name" "$http" "$msg"; ((fail++))
elif echo "$json" | jq -e '.data != null' >/dev/null 2>&1; then
printf '%-28s %-6s OK\n' "$name" "$http"; ((pass++))
else
printf '%-28s %-6s UNEXPECTED: %s\n' "$name" "$http" "$(echo "$json"|head -c 120)"; ((fail++))
fi
done
echo "---"
echo "PASS=$pass FAIL=$fail (skipped $skipped_param parameterized op(s) needing inputs)"
[ "$fail" -eq 0 ]
Loading