diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee4aa5..10e2d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..db4b633 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -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 diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 9d44039..da2bcaa 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -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 @@ -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: diff --git a/docs/releasing.md b/docs/releasing.md index 5d80284..bf09064 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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/): @@ -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 | | --- | --- | @@ -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 diff --git a/docs/self-hosting.md b/docs/self-hosting.md index cb66f7b..b8a3739 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -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 — @@ -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. diff --git a/messages/de.json b/messages/de.json index 4b6f46c..7c706cd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f19a40f..6b7af8c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/[locale]/(app)/layout.tsx b/src/app/[locale]/(app)/layout.tsx index 8e0a526..b31b085 100644 --- a/src/app/[locale]/(app)/layout.tsx +++ b/src/app/[locale]/(app)/layout.tsx @@ -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" @@ -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 ( @@ -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 + } />
diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index ed31891..ed6c48c 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -19,7 +19,8 @@ export function AdminNav() { return (
{open && (
{moduleTabs .filter((tab) => aiAvailable || tab.key !== "chat") .map((tab) => { - const tabHref = `${href}${tab.segment}` - const active = - tab.segment === "" ? pathname === href : pathname.startsWith(tabHref) - return ( - - - {t(tab.key)} - - ) - })} + const tabHref = `${href}${tab.segment}` + const active = tab.segment === "" ? pathname === href : pathname.startsWith(tabHref) + return ( + + + {t(tab.key)} + + ) + })}
)} setAddModuleOpen(true) }, - { label: tCommon("edit"), icon: Pencil, onSelect: () => setEditOpen(true), separatorBefore: true }, + { + label: tCommon("edit"), + icon: Pencil, + onSelect: () => setEditOpen(true), + separatorBefore: true, + }, { label: tCommon("delete"), icon: Trash2, @@ -263,75 +267,75 @@ function SidebarSemester({ }, ]} > -
- - - - - } +
+ + {semester.name} + + + + + } + > + + + + setEditOpen(true)}> + + {tCommon("edit")} + + setDeleteOpen(true)}> + + {tCommon("delete")} + + + + {open && (
{/* A thesis-only semester has no study-plan to generate */} {!(semester.modules.length === 0 && semester.theses.length > 0) && ( - - - {tPlan("title")} - + + + {tPlan("title")} + )} {semester.modules.map((mod) => (
{topItems.map((item) => { - const active = - item.href === "/" ? pathname === "/" : pathname.startsWith(item.href) + const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href) return ( @@ -508,9 +513,7 @@ export function AppSidebar({ /> ))} {context.tree.length === 0 && ( -

- {tContext("noSemesters")} -

+

{tContext("noSemesters")}

)} - - v{APP_VERSION} - +
diff --git a/src/lib/jobs/index.ts b/src/lib/jobs/index.ts index 7b3a526..507ec84 100644 --- a/src/lib/jobs/index.ts +++ b/src/lib/jobs/index.ts @@ -7,6 +7,7 @@ const globalForBoss = globalThis as unknown as { boss?: Promise } export const QUEUE_EMBED_MATERIAL = "embed-material" export const QUEUE_SEND_REMINDERS = "send-reminders" export const QUEUE_DAILY_PLAN = "daily-plan-reminder" +export const QUEUE_CHECK_UPDATES = "check-updates" async function start(): Promise { const boss = new PgBoss({ connectionString: env.DATABASE_URL }) @@ -35,6 +36,17 @@ async function start(): Promise { }) await boss.schedule(QUEUE_DAILY_PLAN, "0 7 * * *") + await boss.createQueue(QUEUE_CHECK_UPDATES) + await boss.work(QUEUE_CHECK_UPDATES, async () => { + const { checkForUpdate } = await import("@/lib/update-check") + try { + await checkForUpdate() + } catch (error) { + console.error("[check-updates]", error) + } + }) + await boss.schedule(QUEUE_CHECK_UPDATES, "0 6 * * *") + return boss } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index ad1e578..4e89a44 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -49,6 +49,16 @@ export const vapidSchema = z.object({ privateKey: z.string().min(1), }) +export const updateCheckSchema = z.object({ + /** Latest release version found on GitHub, e.g. "1.1.0" (no "v" prefix). */ + latestVersion: z.string().min(1), + /** Link to the release on GitHub. */ + htmlUrl: z.string().url(), + publishedAt: z.string(), + /** When this check ran. */ + checkedAt: z.string(), +}) + export const aiProviderTypeSchema = z.enum([ "anthropic", "openai", @@ -97,6 +107,7 @@ const settingsSchemas = { uploads: uploadsSchema, ai: aiSettingsSchema, "push.vapid": vapidSchema, + "system.updateCheck": updateCheckSchema, } as const export type SettingKey = keyof typeof settingsSchemas @@ -120,6 +131,7 @@ const defaults: { [K in SettingKey]: SettingValue } = { uploads: { maxUploadMb: 200 }, ai: { providers: [], monthlyTokenLimitPerUser: 0 }, "push.vapid": undefined as never, // generated on first use + "system.updateCheck": undefined as never, // set once the first check has run } // ---- Store ----------------------------------------------------------------- diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..f41faa6 --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,63 @@ +import "server-only" +import { APP_VERSION, REPO_URL } from "./version" +import { getSetting, setSetting, type SettingValue } from "./settings" + +const [REPO_OWNER, REPO_NAME] = new URL(REPO_URL).pathname.slice(1).split("/") + +/** Parses "1.2.3" into comparable numeric parts. Non-numeric parts sort as 0. */ +function parseVersion(version: string): number[] { + return version + .replace(/^v/, "") + .split(".") + .map((part) => Number.parseInt(part, 10) || 0) +} + +/** True when `latest` is a newer version than `current`. */ +export function isNewerVersion(current: string, latest: string): boolean { + const a = parseVersion(latest) + const b = parseVersion(current) + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) return diff > 0 + } + return false +} + +/** Fetches the latest GitHub release, stores the result, and returns it. */ +export async function checkForUpdate(): Promise> { + const response = await fetch( + `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, + { headers: { Accept: "application/vnd.github+json" } } + ) + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}`) + } + const release = (await response.json()) as { + tag_name: string + html_url: string + published_at: string + } + + const result = { + latestVersion: release.tag_name.replace(/^v/, ""), + htmlUrl: release.html_url, + publishedAt: release.published_at, + checkedAt: new Date().toISOString(), + } + await setSetting("system.updateCheck", result) + return result +} + +/** Cached update-check result plus whether it indicates an update is available. */ +export async function getUpdateStatus(): Promise<{ + current: string + latest: SettingValue<"system.updateCheck"> | null + updateAvailable: boolean +}> { + const latest = await getSetting("system.updateCheck") + return { + current: APP_VERSION, + latest, + updateAvailable: latest ? isNewerVersion(APP_VERSION, latest.latestVersion) : false, + } +}