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
14 changes: 0 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,3 @@ jobs:
- run: npm run typecheck
- run: npm test
- run: npm run build

# Validate the image builds on PRs. Pushes to main/tags publish via release.yml.
docker:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
26 changes: 26 additions & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Release Candidate Check

# Validates that the Docker image builds for a PR marked ready to become a
# release — not for every PR. Add the "release-candidate" label once a PR
# is ready to ship; this then builds (but doesn't push) the image on every
# subsequent push while the label is present, so a broken Dockerfile
# surfaces before merge — before version-bump.yml bumps the version and
# tags the release, not after.

on:
pull_request:
types: [labeled, synchronize]

jobs:
docker-build:
if: contains(github.event.pull_request.labels.*.name, 'release-candidate')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
18 changes: 10 additions & 8 deletions .github/workflows/version-bump.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
name: Version Bump

# Bumps the app version and tags the release the moment a PR is merged into
# main, then explicitly triggers release.yml to build and publish the
# Docker image. No manual release step is needed for normal PRs — see
# docs/releasing.md.
# Bumps the app version and tags the release the moment a PR labeled
# "release-candidate" is merged into main, then explicitly triggers
# release.yml to build and publish the Docker image. No manual release
# step is needed — see docs/releasing.md.
#
# The label is opt-in: PRs are never auto-released unless someone adds
# "release-candidate" to them. Adding the label also triggers a Docker
# build validation before merge (see release-candidate.yml), so a broken
# Dockerfile is caught before the version is bumped and tagged, not after.
#
# Bump type is derived from the PR title (Conventional Commits):
# - a "!" after the type, e.g. "feat!: ..." or "fix!: ..." -> major
# - "feat: ..." / "feat(scope): ..." -> minor
# - anything else (fix, chore, docs, refactor, perf, ...) -> patch
#
# Add the "skip-release" label to a PR to opt it out entirely (e.g. a
# docs-only or CI-only change that shouldn't ship a new image).
#
# Note: the commit + tag below are pushed using the default GITHUB_TOKEN.
# GitHub does not let GITHUB_TOKEN-authored pushes trigger other workflows'
# push events (anti-recursion protection), so release.yml would otherwise
Expand All @@ -36,7 +38,7 @@ jobs:
bump:
if: >
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.labels.*.name, 'skip-release') &&
contains(github.event.pull_request.labels.*.name, 'release-candidate') &&
!startsWith(github.event.pull_request.title, 'chore(release):')
runs-on: ubuntu-latest
steps:
Expand Down
20 changes: 14 additions & 6 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
# Releasing a new version

Version bumps, git tags, and Docker images are all handled automatically by
GitHub Actions. In the normal case there is nothing to do manually.
GitHub Actions, gated behind a label so releases are a deliberate choice.

## How the pipeline works

1. **On every PR merged into `main`**
1. **Mark a PR release-worthy**: add the **`release-candidate`** label to it.
This is opt-in — a PR is never auto-released unless it carries this label
when merged.
- Adding the label (and every subsequent push while it's present) triggers
[`.github/workflows/release-candidate.yml`](../.github/workflows/release-candidate.yml),
which builds (but doesn't push) the Docker image. This surfaces a broken
Dockerfile **before** merge — before any version is bumped or tagged.

2. **On merge, if the label is present**
([`.github/workflows/version-bump.yml`](../.github/workflows/version-bump.yml)):
- The bump type (`patch`/`minor`/`major`) is derived from the PR title
using [Conventional Commits](https://www.conventionalcommits.org/):
Expand All @@ -15,15 +23,15 @@ GitHub Actions. In the normal case there is nothing to do manually.
- `package.json` / `package-lock.json` are bumped, committed directly to
`main` as `chore(release): vX.Y.Z (#PR)`, tagged `vX.Y.Z`, and a GitHub
Release is created.
- Add the **`skip-release`** label to a PR to opt it out entirely (e.g. a
docs-only or CI-only change that shouldn't ship a new image).
- PRs merged **without** the label just merge normally — no version bump,
no tag, no release.
- The commit + tag are pushed with the default `GITHUB_TOKEN`, and GitHub
does not let `GITHUB_TOKEN`-authored pushes trigger other workflows'
`push` events (anti-recursion protection). So the last step explicitly
runs `gh workflow run release.yml --ref vX.Y.Z` to kick off the image
build — `workflow_dispatch` calls are exempt from that restriction.

2. **`release.yml`** builds and publishes the image:
3. **`release.yml`** builds and publishes the image:

| Trigger | Published image tags |
| --- | --- |
Expand All @@ -34,7 +42,7 @@ GitHub Actions. In the normal case there is nothing to do manually.
the merge itself, and the versioned tags (including `:latest`) follow
once `version-bump.yml` tags and dispatches the release build.

3. **Update the deployment** (e.g. Portainer):
4. **Update the deployment** (e.g. Portainer):
- If the stack uses `:latest` (default), re-pull the image and
recreate the container ("Re-pull image and redeploy" in Portainer).
- If the version is pinned, set `STUDYHELPER_VERSION=X.Y.Z` in the
Expand Down
45 changes: 25 additions & 20 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ Update with `docker compose pull && docker compose up -d`. Images are built for
`docker compose` mounts two directories from the **host** into the
containers, so your data survives container restarts/rebuilds:

| Host path (default) | Container path | Contents |
|---|---|---|
| `./data/db` | Postgres data dir | Everything except files: users, modules, flashcards, grades, events, chat history, settings |
| `./data/uploads` | `/data/uploads` | Uploaded files (PDFs, images, …) |
| Host path (default) | Container path | Contents |
| ------------------- | ----------------- | ------------------------------------------------------------------------------------------- |
| `./data/db` | Postgres data dir | Everything except files: users, modules, flashcards, grades, events, chat history, settings |
| `./data/uploads` | `/data/uploads` | Uploaded files (PDFs, images, …) |

By default both live under `./data`, next to `docker-compose.yml`. To store
them elsewhere — a separate disk, a NAS mount, outside the git checkout —
Expand All @@ -65,25 +65,30 @@ first (see Backup below), or set it correctly before the first

Everything else is configured in **Admin → Settings**:

| Area | What |
|---|---|
| Sign-in & SSO | open/closed registration, GitHub/Google login, generic OIDC (Keycloak, Authentik, Zitadel, Authelia, …) |
| AI | providers (Anthropic, OpenAI, Google, Mistral, Groq, Ollama, OpenAI-compatible), models, default + embedding model (enables RAG), monthly token limits |
| Email | SMTP for password resets and reminders, test email |
| Branding | app name, max upload size |
| Area | What |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Sign-in & SSO | open/closed registration, GitHub/Google login, generic OIDC (Keycloak, Authentik, Zitadel, Authelia, …) |
| AI | providers (Anthropic, OpenAI, Google, Mistral, Groq, Ollama, OpenAI-compatible), models, default + embedding model (enables RAG), monthly token limits |
| Email | SMTP for password resets and reminders, test email |
| Branding | app name, max upload size |

A small dot next to the version number in the sidebar (visible to admins)
shows when a newer release is available on GitHub — checked once a day —
and links to the release. Installing it is a manual
`docker compose pull && docker compose up -d`.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `DATABASE_URL` | yes | Postgres connection string (pgvector image) |
| `APP_URL` | yes | Public base URL (auth callbacks, emails, push) |
| `BETTER_AUTH_SECRET` | yes | Session signing secret (32+ random bytes) |
| `ENCRYPTION_KEY` | yes | Encrypts stored secrets (API keys, SMTP, notes) |
| `STUDYHELPER_VERSION` | no | Image tag to run (default `latest`); pin e.g. `1.0.0` for reproducible deploys |
| `DATA_DIR` | no | Host directory for the database + uploads volumes (default `./data`, next to `docker-compose.yml`) — see [Where data is stored](#where-data-is-stored) |
| `UPLOAD_DIR` | no | Upload path **inside the container** (default `/data/uploads`) — only relevant for non-Docker deployments; Docker users should set `DATA_DIR` instead |
| `SEED_TEST_DATA` | no | `true` seeds demo accounts (admin@example.com / admin-test-1234, user@example.com / user-test-1234) with sample study content on startup — for evaluation only, never in production |
| Variable | Required | Description |
| --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | Postgres connection string (pgvector image) |
| `APP_URL` | yes | Public base URL (auth callbacks, emails, push) |
| `BETTER_AUTH_SECRET` | yes | Session signing secret (32+ random bytes) |
| `ENCRYPTION_KEY` | yes | Encrypts stored secrets (API keys, SMTP, notes) |
| `STUDYHELPER_VERSION` | no | Image tag to run (default `latest`); pin e.g. `1.0.0` for reproducible deploys |
| `DATA_DIR` | no | Host directory for the database + uploads volumes (default `./data`, next to `docker-compose.yml`) — see [Where data is stored](#where-data-is-stored) |
| `UPLOAD_DIR` | no | Upload path **inside the container** (default `/data/uploads`) — only relevant for non-Docker deployments; Docker users should set `DATA_DIR` instead |
| `SEED_TEST_DATA` | no | `true` seeds demo accounts (admin@example.com / admin-test-1234, user@example.com / user-test-1234) with sample study content on startup — for evaluation only, never in production |

**Do not lose `ENCRYPTION_KEY`** — encrypted settings (AI keys, SMTP, OIDC secrets) become unreadable without it.

Expand Down
3 changes: 2 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"ai": "KI-Assistent",
"thesis": "Abschlussarbeit",
"settings": "Einstellungen",
"admin": "Admin"
"admin": "Admin",
"updateAvailable": "Version {version} ist auf GitHub verfügbar"
},
"theme": {
"toggle": "Design wechseln",
Expand Down
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"ai": "AI Assistant",
"thesis": "Thesis",
"settings": "Settings",
"admin": "Admin"
"admin": "Admin",
"updateAvailable": "Version {version} is available on GitHub"
},
"theme": {
"toggle": "Toggle theme",
Expand Down
20 changes: 12 additions & 8 deletions src/app/[locale]/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireSession } from "@/lib/auth/session"
import { listAvailableModels, resolveModelForUser } from "@/lib/ai/registry"
import { getAppName } from "@/lib/settings"
import { getStudyContext } from "@/lib/studies/context"
import { getUpdateStatus } from "@/lib/update-check"
import { ChatDock } from "@/components/ai/chat-dock"
import { Pomodoro } from "@/components/pomodoro"
import { PageContextProvider } from "@/components/ai/page-context"
Expand All @@ -12,29 +13,27 @@ import { BottomNav } from "@/components/layout/bottom-nav"

export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession()
const [context, { models }, initialModel, appName] = await Promise.all([
const isAdmin = session.user.role === "admin"
const [context, { models }, initialModel, appName, updateStatus] = await Promise.all([
getStudyContext(session.user.id),
listAvailableModels(),
resolveModelForUser(session.user.id),
getAppName(),
isAdmin ? getUpdateStatus() : null,
])
const aiAvailable = models.length > 0
const user = {
name: session.user.name,
email: session.user.email,
image: session.user.image,
isAdmin: session.user.role === "admin",
isAdmin,
}
const cookieStore = await cookies()
const rawWidth = Number(cookieStore.get("sidebar-width")?.value)
const sidebarWidth = Number.isFinite(rawWidth)
? Math.min(400, Math.max(200, rawWidth))
: 240
const sidebarWidth = Number.isFinite(rawWidth) ? Math.min(400, Math.max(200, rawWidth)) : 240
// All modules of the active program (incl. thesis modules), not just the
// current semester — the chat can be assigned to any of them.
const allModules = context.tree.flatMap((s) =>
s.modules.map((m) => ({ id: m.id, name: m.name }))
)
const allModules = context.tree.flatMap((s) => s.modules.map((m) => ({ id: m.id, name: m.name })))

return (
<PageContextProvider>
Expand All @@ -49,6 +48,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod
aiAvailable={aiAvailable}
appName={appName}
user={user}
newRelease={
updateStatus?.updateAvailable
? { version: updateStatus.latest!.latestVersion, url: updateStatus.latest!.htmlUrl }
: null
}
/>
<div className="flex flex-1 flex-col pb-16 md:pb-0 md:pl-[var(--sidebar-width,15rem)]">
<AppHeader user={user} />
Expand Down
3 changes: 2 additions & 1 deletion src/components/admin/admin-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function AdminNav() {
return (
<nav className="flex gap-1 overflow-x-auto border-b pb-px">
{items.map((item) => {
const active = item.href === "/admin" ? pathname === "/admin" : pathname.startsWith(item.href)
const active =
item.href === "/admin" ? pathname === "/admin" : pathname.startsWith(item.href)
return (
<Link
key={item.key}
Expand Down
Loading
Loading