diff --git a/.claude/agents/release-engineer.md b/.claude/agents/release-engineer.md new file mode 100644 index 0000000..38418e7 --- /dev/null +++ b/.claude/agents/release-engineer.md @@ -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. diff --git a/.claude/settings.json b/.claude/settings.json index 072a314..8b2d26e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": { diff --git a/.claude/skills/adr-new/SKILL.md b/.claude/skills/adr-new/SKILL.md new file mode 100644 index 0000000..a681819 --- /dev/null +++ b/.claude/skills/adr-new/SKILL.md @@ -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-.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: ` — 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. diff --git a/.claude/skills/graphql-smoke/SKILL.md b/.claude/skills/graphql-smoke/SKILL.md new file mode 100644 index 0000000..b2cf7cf --- /dev/null +++ b/.claude/skills/graphql-smoke/SKILL.md @@ -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). diff --git a/.claude/skills/graphql-smoke/smoke.sh b/.claude/skills/graphql-smoke/smoke.sh new file mode 100755 index 0000000..720d86b --- /dev/null +++ b/.claude/skills/graphql-smoke/smoke.sh @@ -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 ]