docs: setup files and the client, shared and CI docs describe what is (#6277)

.dockerignore, .gitignore, deploy/initdb, ci-requirements.md,
client/README.md, shared/README.md and two web/scripts test names: dates,
issue/rule/spike ids and the stories behind each exclusion removed.

Corrected:
- client/README.md: the dev lane bundles its own APK; :main no longer
  exists; main bundles the recorded sidecar instead of re-deriving the
  version name. Its smoke-test step wrote a bare "v0.2.0", which the
  server rejects as a sidecar that is not JSON; it now writes the JSON
  shape, which the README documents.
- ci-requirements.md: one workflow, not four; the android lane builds no
  debug APK; main reads the recorded sidecar.
- .gitignore: CI fills client/ on every image build, not tag releases only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:04:02 -04:00
co-authored by Claude Opus 5.5
parent 557887bb07
commit c96231106f
7 changed files with 68 additions and 82 deletions
+2 -6
View File
@@ -9,12 +9,8 @@ web/build
# The Android client — built by its own job, never from this context. The APK # The Android client — built by its own job, never from this context. The APK
# reaches the image through client/, downloaded as a CI artifact, so nothing # reaches the image through client/, downloaded as a CI artifact, so nothing
# here reads android/ sources. # here reads android/ sources.
# # Excluded so the Gradle project neither bloats the context nor busts the
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when # `COPY . .` layer on every Android-only change.
# that tree was deleted, leaving a comment describing an exclusion that was no
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
# has been entering the context and busting the `COPY . .` layer on every
# Android-only change.
android/ android/
# Local `make build` output — an 18 MB binary the image never uses, since the # Local `make build` output — an 18 MB binary the image never uses, since the
+6 -9
View File
@@ -12,13 +12,11 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB # `make build` output — an 18 MB binary that is never committed.
# binary committed by accident, last refreshed by a commit about web test
# mocks, and re-dirtied by every local build since.
bin/ bin/
# Bundled Android APK + version sidecar (#397). Populated by CI for # Bundled Android APK + version sidecar. Populated by CI on every image
# tag releases; never committed. README in client/ explains the flow. # build; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
client/minstrel.apk.version client/minstrel.apk.version
@@ -44,10 +42,9 @@ go.work.sum
docs/superpowers/ docs/superpowers/
# Per-machine Claude Code settings + remember-skill memory artifacts + # Per-machine Claude Code settings + remember-skill memory artifacts +
# project-level Claude/AI instruction files. All operator-scoped; the # project-level Claude/AI instruction files. All per-machine;
# canonical project memory lives under ~/.claude/projects/ outside the # CLAUDE.md/AGENTS.md/GEMINI.md are tool-specific working notes, not
# repo. CLAUDE.md/AGENTS.md/GEMINI.md stay on the operator's machine # project artifacts.
# only — they're tool-specific working notes, not project artifacts.
.claude/ .claude/
.remember/ .remember/
CLAUDE.md CLAUDE.md
+17 -32
View File
@@ -5,27 +5,20 @@
## Runtime images ## Runtime images
Minstrel's four workflows consume two CI images: Minstrel's one workflow, `.gitea/workflows/release.yml`, consumes two CI images:
``` ```
git.fabledsword.com/bvandeusen/ci-go:1.26 git.fabledsword.com/bvandeusen/ci-go:1.26
git.fabledsword.com/bvandeusen/ci-android:36 git.fabledsword.com/bvandeusen/ci-android:36
``` ```
- `ci-go:1.26` — the `go`, `integration` and `web` lanes, `release-assets`, and the container build (`image-release`). All CI lives in one workflow, `.gitea/workflows/release.yml`, so every publishing job can depend on every lane (rule 177). - `ci-go:1.26` — the `go`, `integration` and `web` lanes, `release-assets`, and the container build (`image-release`). All CI lives in that one workflow so every publishing job can depend on every lane.
- `golang:1.26-bookworm` (Docker Hub) — the `govulncheck` lane. Deliberately the same image as the Dockerfile's builder stage rather than `ci-go`, so the standard library it checks is the one in the shipped binary. Keep the two in step. - `golang:1.26-bookworm` (Docker Hub) — the `govulncheck` lane. Deliberately the same image as the Dockerfile's builder stage rather than `ci-go`, so the standard library it checks is the one in the shipped binary. Keep the two in step.
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (the `android` lane), and the signed release APK (`android-release`). - `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests (the `android` lane), and the signed release APK (`android-release`).
**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.** **No job uses `ci-flutter`**; nothing in this repo needs a Flutter toolchain.
The M8 rewrite replaced the Flutter client with the native Android app and The `flutter-ci` runner *label* is still used: the Android jobs schedule on it
`flutter.yml` was removed; `ci-android` took its place. `flutter_client/` while pulling `ci-android:36`, per the label/image split below.
itself was deleted on 2026-08-16, which was the condition CI-Runner was
waiting on before dropping the image — nothing in this repo needs a Flutter
toolchain any more.
Note this does **not** mean the `flutter-ci` runner *label* goes: the Android
jobs still schedule on it while pulling `ci-android:36`, per the label/image
split below. The label is a scheduling handle, not a toolchain assertion.
## Image deps used ## Image deps used
@@ -43,8 +36,7 @@ split below. The label is a scheduling handle, not a toolchain assertion.
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED` error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
to silence Gradle's launcher-JVM restricted-method warning. to silence Gradle's launcher-JVM restricted-method warning.
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing. - **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
No NDK: the native client has no C/C++ sources (this is why it isn't on No NDK: the app has no C/C++ sources.
`ci-flutter`).
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in - **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
the `android` lane. Image pins track `android/gradle/libs.versions.toml` so local the `android` lane. Image pins track `android/gradle/libs.versions.toml` so local
and CI checks agree. and CI checks agree.
@@ -61,32 +53,25 @@ None.
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way. - **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
- **Integration-job docker-socket dependency.** The `integration` lane uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step. - **Integration-job docker-socket dependency.** The `integration` lane uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows. - **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved. - **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on dev and tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. On `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and the version sidecar recorded beside it, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release or APK asset can be resolved.
- **Cache server reachability.** The `web` lane does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. - **Cache server reachability.** The `web` lane does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network, so `setup-node` would spend minutes on ETIMEDOUT before failing open. `setup-node` is not used at all (Node is in the image). The cache concern reappears if a change introduces a network-dependent action.
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.** - **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
```yaml ```yaml
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
``` ```
Stock works on this forge since the runner moved to gitea/runner 3.x, which Stock works on this forge because gitea/runner 3.x edits the actions'
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven client-side `isGhes()` refusal out of their bundles; upload v4–v7 and
on 2026-09-10 for upload v4–v7 and download v4–v8 (Scribe spike #3843). Until download v4–v8 are proven.
then this repo pinned SHA mirrors of the Forgejo project's forks, because
upstream threw on the hostname before it opened a connection (Scribe 2255).
`@v3` is still broken: it reports success, and Gitea serves artifacts back only `@v3` is still broken: it reports success, and Gitea serves artifacts back only
through the v4 API (`content_encoding = application/zip`), so a v3 upload is through the v4 API (`content_encoding = application/zip`), so a v3 upload is
stored but invisible to every retrieval path. That is how 72 unreachable stored but invisible to every retrieval path.
artifacts accumulated on this repo (Scribe 2270).
**Pairing no longer needs managing.** This entry used to pin upload v5 against **The upload and download majors need not match.** Every download major
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch v4–v8 reads the artifacts of every upload major v4–v7, by name and by
across `release.yml`'s producer/consumer pair would list empty. Tested, and not pattern; the only protocol break is v3 → v4. Every CI-runner image carries
true on this instance: every download major v4–v8 read the artifacts of every Node 24, and the runner runs actions with the image's `node`.
upload major v4–v7, by name and by pattern (CI-runner run 6312). The only real
protocol break is v3 → v4. node24 is no longer a concern either — every
CI-runner image carries Node 24 and the runner runs actions with the image's
`node`.
Upload steps set `if-no-files-found: error` rather than the default `warn`, so Upload steps set `if-no-files-found: error` rather than the default `warn`, so
an upload that matches nothing fails its own job instead of failing the an upload that matches nothing fails its own job instead of failing the
+38 -25
View File
@@ -1,45 +1,58 @@
# Bundled client assets # Bundled client assets
Holds the Android APK (`minstrel.apk` + `minstrel.apk.version`) that the Holds the Android APK (`minstrel.apk`) and its version sidecar
server serves via `/api/client/version` and `/api/client/apk` for the (`minstrel.apk.version`) that the server serves via `/api/client/version`
in-app update flow (#397). and `/api/client/apk` for the in-app update flow.
## Production ## Production
CI populates this directory in `release.yml` (no Flutter — the client is CI fills this directory in `release.yml` before every image build:
native Android, built by the `android-release` job):
- **Tag release (`v*`):** the `android-release` job builds + signs the - **Dev push (`:dev`) and tag release (`v*`):** the `android-release` job
APK, uploads it as a workflow artifact, and attaches it to the Gitea builds and signs the APK and uploads it as a workflow artifact;
release as `minstrel-<TAG>.apk`. The `image-release` job `image-release` downloads it here as `minstrel.apk` and writes the
(`needs: android-release`) downloads that artifact into this directory sidecar from that build's version outputs. A tag release also attaches
as `minstrel.apk` and writes the computed versionName to both files to the Gitea release.
`minstrel.apk.version`. - **Main push (`:latest` + `:<sha>`):** no APK is built, but the image
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the still moves `:latest`, so `image-release` downloads the most recent
image still moves `:latest`, so `image-release` pulls the most-recent release's APK and the sidecar recorded beside it. `:latest` keeps its
release's signed APK from the Gitea API and reconstructs its exact update channel between releases.
versionName — keeping the in-app update channel alive on `:latest`
instead of stripping it until the next tag.
Either way, `docker buildx build` bakes both files into the image via Either way, `docker buildx build` bakes both files into the image via
`COPY client/ /app/client/`. `COPY client/ /app/client/`.
## The sidecar
`minstrel.apk.version` is JSON:
```json
{"name": "2026.10.23.1200", "code": 3549060, "channel": "dev"}
```
- `name` is the version name the app shows.
- `code` is the ordering key Android installs by. `null` means unknown,
and clients then compare names.
- `channel` says which lane built it.
A sidecar that is not valid JSON, or has no `name`, is an error rather
than a blank version.
## Development ## Development
Empty directory works fine — the endpoints return 404 and the Android An empty directory works fine: the endpoints return 404 and the Android
update banner stays hidden ("no update channel" graceful degradation). update banner stays hidden.
To smoke-test the update flow locally, drop a real APK + a version To smoke-test the update flow locally, drop a real APK and a sidecar into
file into this directory: this directory:
```sh ```sh
cp /path/to/app-release.apk client/minstrel.apk cp /path/to/app-release.apk client/minstrel.apk
echo "v0.2.0" > client/minstrel.apk.version echo '{"name":"2099.01.01.0000","code":null,"channel":"dev"}' > client/minstrel.apk.version
``` ```
## Why a directory and not embed.FS? ## Why a directory and not embed.FS?
The APK is 30-60 MB. Embedding bloats the Go binary and slows The APK is 30-60 MB. Embedding bloats the Go binary and slows `go build`
`go build` for everyone, even when the APK isn't being changed. for everyone, even when the APK isn't being changed. File-on-disk also
File-on-disk also lets operators override at runtime via volume lets an admin override it at runtime with a volume mount on
mount on `/app/client/` if they want to ship their own build. `/app/client/` to ship their own build.
+1 -2
View File
@@ -1,6 +1,5 @@
-- Runs once, only on a fresh Postgres data volume (Docker entrypoint -- Runs once, only on a fresh Postgres data volume (Docker entrypoint
-- initdb hook). Creates the dedicated integration-test database so -- initdb hook). Creates the dedicated integration-test database so
-- `go test` never truncates the operator's dev `minstrel` DB (Fable -- `go test` never truncates the dev `minstrel` DB. For an already-initialised volume this script does NOT run;
-- #339). For an already-initialised volume this script does NOT run;
-- `make test-integration` creates the DB idempotently instead. -- `make test-integration` creates the DB idempotently instead.
CREATE DATABASE minstrel_test OWNER minstrel; CREATE DATABASE minstrel_test OWNER minstrel;
+2 -6
View File
@@ -3,12 +3,8 @@
`fabledsword.tokens.json` is the canonical statement of the FabledSword `fabledsword.tokens.json` is the canonical statement of the FabledSword
palette — the dark, light and flat cohorts in one machine-readable place. palette — the dark, light and flat cohorts in one machine-readable place.
It lived under `flutter_client/shared/` until that client was deleted The Android theme (`FabledSwordTokens.kt`) names it as its source of
(2026-08-16). It was never Flutter-specific: the Android theme truth, and the web client's CSS custom properties carry the same values.
(`FabledSwordTokens.kt`) names it as its source of truth, and the web
client's CSS custom properties carry the same values. It moved here rather
than going with the client, because losing the one place all three cohorts
are written down together would have been collateral damage.
Nothing generates from it today — both clients hold their own transcription, Nothing generates from it today — both clients hold their own transcription,
so this is the reference they must agree with, not a build input. If the so this is the reference they must agree with, not a build input. If the
+2 -2
View File
@@ -5,7 +5,7 @@ import { findCss, findRawText, findTailwind, scan } from './check-tint-contrast.
const web = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const web = resolve(dirname(fileURLToPath(import.meta.url)), '..');
describe('hue-on-own-tint check (#3150)', () => { describe('hue-on-own-tint check', () => {
test('no source paints a hue as text on a tint of itself', () => { test('no source paints a hue as text on a tint of itself', () => {
expect(scan(resolve(web, 'src'), web)).toEqual([]); expect(scan(resolve(web, 'src'), web)).toEqual([]);
}); });
@@ -42,7 +42,7 @@ describe('hue-on-own-tint check (#3150)', () => {
expect(findRawText(`.msg { color: var(--fs-error); }`)[0].fix).toBe('var(--fs-error-fg)'); expect(findRawText(`.msg { color: var(--fs-error); }`)[0].fix).toBe('var(--fs-error-fg)');
}); });
test('raw accent text is caught too, including hover: variants (#5318)', () => { test('raw accent text is caught too, including hover: variants', () => {
expect(findRawText(`<a class="text-sm text-accent hover:underline">`)[0].fix).toBe('text-accent-fg'); expect(findRawText(`<a class="text-sm text-accent hover:underline">`)[0].fix).toBe('text-accent-fg');
expect(findRawText(`<a class="hover:text-accent">`)).toHaveLength(1); expect(findRawText(`<a class="hover:text-accent">`)).toHaveLength(1);
expect(findRawText(`.pending { color: var(--fs-accent); }`)[0].fix).toBe('var(--fs-accent-fg)'); expect(findRawText(`.pending { color: var(--fs-accent); }`)[0].fix).toBe('var(--fs-accent-fg)');