Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
111 changes: 111 additions & 0 deletions .github/workflows/howto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: How-To docs

# Runs on every pull request (open + each push) and on manual dispatch.
# It stands up a throwaway AudioMuse-AI stack (prebuilt image + empty Postgres +
# Redis), drives a headless browser over every page with all data mocked in the
# browser, renders the version-stamped howto.md, validates it, and uploads the
# whole docs/howto/<version>/ folder as a build artifact for review.
#
# It does NOT commit anything and never touches main: you download the artifact,
# and if you like the result you commit it yourself.
#
# The version (and therefore the docs/howto/<version> folder, with the leading
# "v" stripped) is read from APP_VERSION in config.py — not from any git tag.

on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
image:
description: 'Override the app image (default ghcr.io/neptunehub/audiomuse-ai:<version>, falls back to :latest)'
required: false

permissions:
contents: read
packages: read

jobs:
capture:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Resolve version from config.py
id: ver
run: |
OUT=$(python docs/howto/_tooling/_version.py)
V=$(echo "$OUT" | awk '{print $1}')
NUM=$(echo "$OUT" | awk '{print $2}')
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "num=$NUM" >> "$GITHUB_OUTPUT"
echo "config.py APP_VERSION: $V -> docs/howto/$NUM"

- name: Pick app image (version tag, else latest)
id: img
run: |
OVERRIDE="${{ github.event.inputs.image }}"
if [ -n "$OVERRIDE" ]; then
IMG="$OVERRIDE"
else
IMG="ghcr.io/neptunehub/audiomuse-ai:${{ steps.ver.outputs.version }}"
if ! docker manifest inspect "$IMG" >/dev/null 2>&1; then
echo "::warning::$IMG not found, falling back to :latest"
IMG="ghcr.io/neptunehub/audiomuse-ai:latest"
fi
fi
echo "image=$IMG" >> "$GITHUB_OUTPUT"
echo "Using image: $IMG"

- name: Start app stack
env:
HOWTO_IMAGE: ${{ steps.img.outputs.image }}
run: docker compose -f docs/howto/_tooling/docker-compose.howto.yml up -d

- name: Wait for /api/health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:8000/api/health >/dev/null 2>&1; then
echo "App is up after ${i} tries."
exit 0
fi
sleep 5
done
echo "App did not become healthy in time."
docker compose -f docs/howto/_tooling/docker-compose.howto.yml logs --tail=200 flask
exit 1

- name: Install Playwright + Chromium
run: |
pip install -r docs/howto/_tooling/requirements.txt
python -m playwright install --with-deps chromium

- name: Capture screenshots (all data mocked in the browser)
run: |
python docs/howto/_tooling/howto_capture.py \
--base-url http://localhost:8000 \
--user admin --password adminpass \
--mock-all --browser-channel ""

- name: Render + validate howto.md
run: |
python docs/howto/_tooling/render_howto.py
python docs/howto/_tooling/validate_howto.py

- name: Tear down stack
if: ${{ always() }}
run: docker compose -f docs/howto/_tooling/docker-compose.howto.yml down -v

- name: Upload how-to bundle for review
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: howto-${{ steps.ver.outputs.num }}
path: docs/howto/${{ steps.ver.outputs.num }}/
if-no-files-found: error
93 changes: 93 additions & 0 deletions docs/howto/_tooling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# How-To guide tooling

Generates the per-release user guide under `docs/howto/<version>/` — a
GitHub-readable `howto.md` (table of contents + a section and screenshot per
page) plus a `screenshots/` folder.

```
docs/howto/
_tooling/ <- this folder (scripts + prose template + CI stack)
howto.template.md the guide prose, with a {{VERSION}} placeholder
howto_capture.py drives a browser, screenshots every page
render_howto.py template + version -> docs/howto/<version>/howto.md (stdlib)
validate_howto.py checks a rendered folder is complete & correct (stdlib)
make_howto.py convenience: capture + render in one go
docker-compose.howto.yml throwaway app + Postgres + Redis used by CI
_version.py reads APP_VERSION from config.py
requirements.txt playwright (capture only)
2.1.4/ <- a rendered release (howto.md + screenshots/)
```

## Safety (why the output is publishable)

* **Copyright** — track **title / artist / album** never appear. When capturing
against a real instance every `/api/**` JSON response is intercepted and those
fields are rewritten to placeholders (`Song Title 1`, …) before the page
renders. When capturing in CI (`--mock-all`) the data is fabricated as
placeholders to begin with.
* **Secrets** — server URLs, user IDs, tokens and passwords are blanked on the
Setup, Sonic Fingerprint, Analysis, Instant Playlist and Users pages.
* Only **read-only** features are exercised; nothing is written to a media
server. A few "result" screenshots use representative placeholder data.

## Two ways to produce the guide

### A) In CI on each pull request (no real instance) — `.github/workflows/howto.yml`

Runs on every pull request (open + each push) and on manual dispatch. The
version — and therefore the `docs/howto/<version>/` folder name, with the
leading `v` stripped — is read from `APP_VERSION` in `config.py`, **not** from a
git tag. The workflow:

1. starts a throwaway stack from `docker-compose.howto.yml` — the prebuilt
image + an **empty** Postgres + Redis. Env vars clear the setup/auth barrier
(no media server is contacted) and seed an `admin` account on boot.
2. waits for `GET /api/health`, then runs `howto_capture.py --mock-all` — which
logs in and **fabricates every page's data in the browser**, so an empty
database still yields fully-populated screenshots.
3. renders `howto.md`, validates the folder, and **uploads
`docs/howto/<version>/` as a build artifact**.

It deliberately does **not** commit anything and never pushes to `main`:
download the artifact from the run, review the screenshots and `howto.md`, and
if you like the result commit the folder yourself (or just run option B locally
and commit). The app image defaults to
`ghcr.io/neptunehub/audiomuse-ai:<version>`, falling back to `:latest`; if that
image is private, add a `docker/login-action` step before "Start app stack".

`--mock-all` notes: data endpoints (`/api/search_tracks`, `/api/map`,
`/api/dashboard/summary`, …) are answered from `build_mock()` in
`howto_capture.py`; config/setup/users endpoints pass through to the real app
(and are masked). Lyrics and DCLAP gate their inputs server-side on built
indexes, which an empty DB doesn't have — so in mock mode those inputs are
re-enabled and the "index not built" banner is hidden before the demo runs.

If you add a page or change an endpoint's response shape, update `build_mock()`
to match.

### B) Locally, against your own analysed instance

```bash
pip install -r docs/howto/_tooling/requirements.txt
playwright install chromium # or rely on an installed Google Chrome (default channel)

cd docs/howto/_tooling
python make_howto.py --base-url http://192.168.3.204:8000 --user root --password root
```

Writes `docs/howto/<APP_VERSION>/` (version from `config.py`; override with
`--version v2.2.0`). Real metadata is masked on the way through. Commit the
folder. URL/credentials also read from `HOWTO_BASE_URL` / `HOWTO_USER` /
`HOWTO_PASSWORD`.

* Prose change for everyone? Edit `howto.template.md` once, then
`python render_howto.py --version vX.Y.Z` (no browser).
* Re-render only (no recapture): `make_howto.py --skip-capture`.

## Tweaking individual scripts

* `render_howto.py` / `validate_howto.py` are pure stdlib (no browser, no app) —
safe and fast to run anywhere, including as a release gate.
* `validate_howto.py` fails if a referenced screenshot is missing/empty, an
in-page link is broken (GitHub heading-slug rules), or the page isn't stamped
with the expected version.
47 changes: 47 additions & 0 deletions docs/howto/_tooling/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Resolve the application version from config.py without importing it.

config.py pulls in heavy runtime dependencies, so the version is read by
parsing the source with the ast module instead (same approach the standalone
build uses in scripts/standalone/config.py).
"""
import ast
import os
from pathlib import Path

# docs/howto/_tooling/_version.py -> repo root is three parents up.
REPO_ROOT = Path(__file__).resolve().parents[3]


def read_app_version(repo_root=REPO_ROOT):

Check failure on line 15 in docs/howto/_tooling/_version.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=NeptuneHub_AudioMuse-AI&issues=AZ6sw5FPF1QkhlkwgByR&open=AZ6sw5FPF1QkhlkwgByR&pullRequest=622
"""Return APP_VERSION exactly as written in config.py, e.g. 'v2.1.4'."""
cfg = os.path.join(str(repo_root), "config.py")
with open(cfg, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "APP_VERSION":
if isinstance(node.value, ast.Constant):
return str(node.value.value)
raise RuntimeError("APP_VERSION not found in config.py")


def folder_version(version):
"""Strip a leading 'v' so 'v2.1.4' -> '2.1.4' (used for the folder name)."""
return version[1:] if version[:1] in ("v", "V") else version


def display_version(version):
"""Normalise to the 'vX.Y.Z' form shown in the document."""
return version if version[:1] in ("v", "V") else "v" + version


def resolve(version=None, repo_root=REPO_ROOT):
"""Return (display, folder) for an explicit tag or the value in config.py."""
raw = version or read_app_version(repo_root)
return display_version(raw), folder_version(raw)


if __name__ == "__main__":
disp, folder = resolve()
print(disp, folder)
59 changes: 59 additions & 0 deletions docs/howto/_tooling/docker-compose.howto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Throwaway AudioMuse-AI stack for capturing how-to screenshots in CI.
#
# Boots the app from the prebuilt release image against an EMPTY Postgres + Redis.
# Env vars clear the setup/auth barrier (no real media server is ever contacted)
# and seed an admin on boot, so the capture can log in. All page DATA is mocked
# in the browser by howto_capture.py --mock-all, so no real library is needed.
#
# Image tag is parameterised: HOWTO_IMAGE=ghcr.io/neptunehub/audiomuse-ai:v2.2.0
# (defaults to :latest). Bring up with:
# HOWTO_IMAGE=... docker compose -f docs/howto/_tooling/docker-compose.howto.yml up -d

services:
redis:
image: redis:7-alpine
restart: unless-stopped

postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: audiomuse
POSTGRES_PASSWORD: audiomusepassword
POSTGRES_DB: audiomusedb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U audiomuse -d audiomusedb"]
interval: 5s
timeout: 5s
retries: 30
restart: unless-stopped

flask:
image: ${HOWTO_IMAGE:-ghcr.io/neptunehub/audiomuse-ai:latest}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
environment:
SERVICE_TYPE: "flask"
TZ: "UTC"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_USER: "audiomuse"
POSTGRES_PASSWORD: "audiomusepassword"
POSTGRES_DB: "audiomusedb"
REDIS_URL: "redis://redis:6379/0"
TEMP_DIR: "/app/temp_audio"
# --- clear the setup barrier (values are never contacted, just validated) ---
MEDIASERVER_TYPE: "jellyfin"
JELLYFIN_URL: "http://media.example.com"
JELLYFIN_USER_ID: "ci-user"
JELLYFIN_TOKEN: "ci-token"
# --- auth: seed an admin on boot so the capture can log in ---
AUTH_ENABLED: "true"
AUDIOMUSE_USER: "admin"
AUDIOMUSE_PASSWORD: "adminpass"
# CLAP_ENABLED / LYRICS_ENABLED default true → all nav links appear
ports:
- "8000:8000"
restart: unless-stopped
Loading
Loading