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
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ jobs:
just ci::run just check::build -c backend
just ci::run just check::build -c frontend

# Fails on any Sphinx warning, so a broken link cannot be merged. The
# `docs` job below publishes the same build from main.
- name: Docs
run: just ci::run just docs::build

- name: Test
run: |
just ci::run just check::test -c backend
Expand Down Expand Up @@ -212,3 +217,55 @@ jobs:

- name: Publish chart if its version is unpublished
run: just ci::run just helm::publish

# Publishes the user docs (docs/) to GitHub Pages. From main only: that is
# what the dev instance runs, and the docs say so on every page. Pages must be
# enabled in the repository settings with "GitHub Actions" as the source.
docs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 30
needs: [ci]
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
# One deployment at a time, and never cancel one halfway: a newer push
# queues behind it instead.
concurrency:
group: pages
cancel-in-progress: false

steps:
# `fetch-depth: 0` so the version in the docs can see the tags.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0

- uses: cachix/install-nix-action@ba0dd844c9180cbf77aa72a116d6fbc515d0e87b # v27
with:
extra_nix_config: |
experimental-features = nix-command flakes

- uses: cachix/cachix-action@ad2ddac53f961de1989924296a1f236fcfbaa4fc # v15
with:
name: hackagon
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

- name: Install bootstrap tools
run: nix profile install --accept-flake-config ./tools/nix#bootstrap

- name: Build docs
run: just ci::run just docs::build

- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0

- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: .output/docs/build/docs

- id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Added

- There is now user documentation, published on GitHub Pages: a guide for
hackathon owners and one for participants, with a page and a screenshot for
every entry in a hackathon's menu. The Settings page there explains each
checkbox, step by step. The documentation describes the latest development
version, and every page says which one.
- The Settings page shows when you have changed a switch without saving, as
Manage Teams does: a bar at the top lists what is unsaved, with Save and
Discard, and leaving the page asks first. Saving keeps you on the page.
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ components/backend/
├── internal/logx/logx.go # slog setup + Fatal helper
└── Schema.md # human-readable DB reference (auto-generated)
components/frontend/ # SvelteKit; generated gRPC clients under src/lib/server/grpc/generated/
docs/ # user documentation: Sphinx + Markdown (MyST), one guide per role, under src/
helm-chart/ # Helm chart; published as an OCI artifact on a v* tag
CHANGELOG.md # user-visible changes; the entry lands in the PR
RELEASING.md # branch model + how a change reaches a cluster
Expand Down Expand Up @@ -104,6 +105,9 @@ just version::show # declared version + what a build stamps in the
just version::check # fail if a component version has drifted from VERSION
just version::bump patch|minor|major # edit VERSION everywhere, commit, annotated tag

just docs::build # build the user docs; any Sphinx warning (e.g. a broken link) fails
just docs::serve [port] # live preview of the docs while writing, default :8090

just helm::lint # helm lint + full render against tools/helm/lint-values.yaml
just helm::template [args] # render the chart to stdout
just helm::check-bump # fail if helm-chart/ changed without a version bump
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,11 @@
<!-- eslint-disable svelte/no-navigation-without-resolve -- these hrefs are
built with resolve() in $lib/navigation; the rule only recognizes a
literal resolve() call in the attribute itself. -->
<!-- `data-nav-id` is what `just docs::screenshots` clicks: the id outlives
a renamed label, and names the doc page the screenshot is for. -->
<a
href={item.href}
data-nav-id={item.id}
aria-current={isActive ? 'page' : undefined}
title={collapsed ? item.label : undefined}
class="flex h-10 items-center gap-2 rounded-control px-2 text-sm no-underline
Expand Down
11 changes: 11 additions & 0 deletions docs/.component.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: docs
version: 0.1.0
language: none

# The user documentation: a Sphinx site written in Markdown (MyST), published to
# GitHub Pages from main. `just docs::serve` previews it while writing.
targets:
build:
steps:
- runnerID: hackagon::doc-sphinx
inputs: ["self"]
2 changes: 2 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv
__pycache__
23 changes: 23 additions & 0 deletions docs/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[project]
name = "hackagon-docs"
version = "0.1.0"
description = "User documentation for the Hackagon hackathon platform"
requires-python = ">=3.11"
dependencies = [
"sphinx>=8.1",
# Markdown instead of reStructuredText, so the docs are formatted and
# written like every other .md file in the repo.
"myst-parser>=4.0",
"furo>=2024.8.6",
]

[dependency-groups]
# `just docs::serve`: rebuilds and reloads the browser on every save.
dev = ["sphinx-autobuild>=2024.10.3"]
# `just docs::screenshots`, only. Not a default group, so the docs build in CI
# never installs a browser.
screenshots = ["playwright>=1.48"]

[tool.uv]
# A set of pages, not an installable package.
package = false
165 changes: 165 additions & 0 deletions docs/screenshots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Retake the screenshots of hackathon sidebar pages for the user docs.

Only on demand, and only the pages named:

just docs::screenshots manage-teams member-voting
just docs::screenshots --all

Each name is a section of screenshots.toml and a doc page under src/. The
picture lands in src/static/screenshots/<name>.png, and the run ends by saying
which pictures changed — the doc pages to reread.

Needs the local stack (`just start`) with seeded data (`just db::seed`): it logs
in through Keycloak as a seeded user, opens the hackathon from the dashboard and
clicks the sidebar entry by its `data-nav-id`, so an entry that is missing
fails loudly instead of photographing the wrong page.
"""

import argparse
import hashlib
import os
import sys
import tomllib
from pathlib import Path

from playwright.sync_api import Page, sync_playwright

DOCS = Path(__file__).parent
MANIFEST = DOCS / "screenshots.toml"
OUT = DOCS / "src" / "static" / "screenshots"

APP = os.environ.get("HACKAGON_URL", "http://localhost:8081").rstrip("/")
# The dev password every seeded login shares (see CLAUDE.md).
PASSWORD = os.environ.get("HACKAGON_PASSWORD", "aliceandbob")

# A typical laptop, at double density so text stays sharp on a retina screen.
# Only the first screen of a page: a roster of a hundred people photographed
# whole is a picture nobody reads.
VIEWPORT = {"width": 1440, "height": 900}
SCALE = 2


class ShotError(Exception):
pass


def digest(path: Path) -> str | None:
return hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None


def log_in(page: Page, user: str) -> None:
# Settled first: "Log in" is a client-side handler, and a click that lands
# before the page has hydrated does nothing.
page.goto(f"{APP}/", wait_until="networkidle")
page.get_by_role("button", name="Log in").click()
page.locator("#username").fill(user)
# Keycloak shows the password on the same form or, with a username-first
# flow, on the next one.
if not page.locator("#password").is_visible():
page.locator("#kc-login").click()
page.locator("#password").fill(PASSWORD)
page.locator("#kc-login").click()
page.wait_for_url(f"{APP}/**")
if page.get_by_role("button", name="Log in").is_visible():
raise ShotError(f"could not log in as {user}")


def open_page(page: Page, name: str, shot: dict) -> None:
page.goto(f"{APP}/dashboard")
page.wait_for_load_state("networkidle")
# A dashboard row is a link named after its hackathon (aria-label).
hackathon = page.get_by_role("link", name=shot["hackathon"], exact=True)
if hackathon.count() == 0:
raise ShotError(
f"{shot['user']} has no '{shot['hackathon']}' on the dashboard. "
"Seeded data missing? Try `just db::seed`."
)
hackathon.first.click()
page.wait_for_url("**/my/hackathon/**")

page.wait_for_load_state("networkidle")
if page.locator("[data-nav-id]").count() == 0:
raise ShotError(
f"the sidebar at {APP} has no data-nav-id at all: the running app is "
"older than this script. Is the stack running from this checkout?"
)
entry = page.locator(f'[data-nav-id="{shot["nav"]}"]:visible')
if entry.count() == 0:
raise ShotError(
f"{shot['nav']} is not in {shot['user']}'s sidebar in "
f"'{shot['hackathon']}'. Is the capability on, or are there teams, "
f"phases or tracks there? Pick another hackathon for [{name}]."
)
# SvelteKit navigates client-side: the click starts no page load, so waiting
# for one returns at once and photographs the page we came from. Wait for
# the address the entry points at instead — or one below it, since some
# entries redirect on (Manage Forms opens its first form).
href = entry.first.get_attribute("href") or ""
entry.first.click()
page.wait_for_url(lambda url: href in url)
page.wait_for_load_state("networkidle")
# Off the page, so no hover state ends up in the picture.
page.mouse.move(VIEWPORT["width"] - 1, VIEWPORT["height"] - 1)


def main() -> int:
manifest = tomllib.loads(MANIFEST.read_text())

parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("pages", nargs="*", help="pages to retake")
parser.add_argument("--all", action="store_true", help="retake every page")
args = parser.parse_args()

names = list(manifest) if args.all else args.pages
if not names:
parser.error(f"name the pages to retake, or --all. Pages: {' '.join(manifest)}")
unknown = [n for n in names if n not in manifest]
if unknown:
parser.error(f"unknown {', '.join(unknown)}. Pages: {' '.join(manifest)}")

OUT.mkdir(parents=True, exist_ok=True)
changed, unchanged, failed = [], [], []

# One login per user, then every page of theirs.
by_user: dict[str, list[str]] = {}
for name in names:
by_user.setdefault(manifest[name]["user"], []).append(name)

with sync_playwright() as pw:
browser = pw.chromium.launch()
for user, pages in by_user.items():
context = browser.new_context(viewport=VIEWPORT, device_scale_factor=SCALE)
# The docs are light by default; the app follows the stored mode.
context.add_init_script("localStorage.setItem('mode', 'light')")
page = context.new_page()
try:
log_in(page, user)
except Exception as err: # noqa: BLE001 — reported per page below
failed += [(n, str(err)) for n in pages]
context.close()
continue

for name in pages:
path = OUT / f"{name}.png"
before = digest(path)
try:
open_page(page, name, manifest[name])
page.screenshot(path=path, animations="disabled")
except Exception as err: # noqa: BLE001 — one bad page, not the run
failed.append((name, str(err).splitlines()[0]))
continue
(unchanged if digest(path) == before else changed).append(name)
context.close()
browser.close()

for name in changed:
print(f"changed {name} → reread src/*/{name}.md")
for name in unchanged:
print(f"unchanged {name}")
for name, err in failed:
print(f"FAILED {name}: {err}", file=sys.stderr)
return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading