diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 36133eb..b387cf8 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -87,6 +87,42 @@ jobs: kubeconform -schema-location '/tmp/app-schema.json' \ -summary examples/static-inline-app.yaml examples/static-git-app.yaml + versions: + name: Version strings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # release-please bumps all of these together; a hand edit to one of them + # would ship mismatched versions. + - name: Every version string matches .release-please-manifest.json + run: | + python3 - <<'PY' + import json, re, sys + want = json.load(open(".release-please-manifest.json"))["."] + def grep(path, pattern): + m = re.search(pattern, open(path).read(), re.M) + return m.group(1) if m else None + lock = json.load(open("ui/package-lock.json")) + found = { + "Chart.yaml version": grep("charts/nebari-apps/Chart.yaml", r"^version:\s*\"?([^\"\s#]+)"), + "Chart.yaml appVersion": grep("charts/nebari-apps/Chart.yaml", r"^appVersion:\s*\"?([^\"\s#]+)"), + "api __version__": grep("api/src/nebari_apps_api/__init__.py", r"^__version__ = \"([^\"]+)\""), + "mcp __version__": grep("mcp/src/nebari_apps_mcp/__init__.py", r"^__version__ = \"([^\"]+)\""), + "ui/package.json": json.load(open("ui/package.json"))["version"], + "ui/package-lock.json": lock["version"], + "ui/package-lock.json packages['']": lock["packages"][""]["version"], + } + bad = {k: v for k, v in found.items() if v != want} + for k, v in found.items(): + print(("ok " if v == want else "FAIL ") + f"{k}: {v}") + if bad: + print(f"::error::version strings must all be {want} (the release-please manifest)") + sys.exit(1) + PY + pins: name: Runtime pins runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 0000000..fa2bc2c --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,42 @@ +name: Release PR + +# Keeps one open "chore: release X.Y.Z" pull request that bumps every version +# string (Chart.yaml version and appVersion, the api and mcp __version__, +# ui/package.json and its lockfile) and prepends the release's CHANGELOG.md +# section, built from the conventional-commit titles merged since the last +# release. Merging it is the release: the Chart.yaml bump runs release.yaml. +# +# release-please never creates the tag or GitHub Release itself +# (skip-github-release): pack-release does, as nebari-apps-, and the +# tag format below matches it so release-please finds the previous release. +# Labels are skipped too, so a merged release PR is never left "pending". + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + # A PR opened with the default GITHUB_TOKEN does not trigger CI; set + # RELEASE_PLEASE_TOKEN (a bot token with contents + pull-requests + # write) so the release PR runs its checks without a manual reopen. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true + skip-labeling: true diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b00166 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +Maintained by [release-please](https://github.com/googleapis/release-please) from the +conventional-commit titles of merged pull requests. See [Releases](README.md#releases). + +## 0.1.0 (2026-08-26) + +First release: the App CRD and apps-operator, apps-api, apps-ui and apps-mcp, and the +`nebari-apps` Helm chart. See the [v0.1.0 release](https://github.com/nebari-dev/apps-pack/releases/tag/v0.1.0). diff --git a/README.md b/README.md index 5d0b971..95f424d 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,14 @@ Alpha means the happy path works; these are the gaps to know about before a demo # App namespaces first: the chart grants the API a RoleBinding in each one # listed in api.allowedNamespaces (default: apps). kubectl create namespace apps && kubectl label namespace apps nebari.dev/managed=true +# From a checkout: the chart's default image tags are its appVersion, which +# only matches images at a release commit. On main, use the images main builds. helm install nebari-apps charts/nebari-apps \ --namespace nebari-apps --create-namespace \ --set clusterDomain= \ - --set keycloak.url=https://keycloak./auth + --set keycloak.url=https://keycloak./auth \ + --set operator.image.tag=latest --set api.image.tag=latest \ + --set ui.image.tag=latest --set mcp.image.tag=latest kubectl label namespace nebari-apps nebari.dev/managed=true ``` @@ -286,9 +290,21 @@ with cosign, with a build-provenance attestation and an SBOM. The same push also pins exact `sha-` tags. Pull requests build the images without pushing, and every image is scanned with Trivy; a HIGH or CRITICAL vulnerability with an available fix fails the check. -**Releasing is a chart version bump.** Merging a change to `version` in -[`charts/nebari-apps/Chart.yaml`](charts/nebari-apps/Chart.yaml) runs -[`release.yaml`](.github/workflows/release.yaml), which uses the org's `pack-release` +**Releasing is merging the release PR.** +[`release-please.yaml`](.github/workflows/release-please.yaml) keeps one open +`chore: release X.Y.Z` pull request, opened by +[release-please](https://github.com/googleapis/release-please). It bumps every version string +together (Chart `version` and `appVersion`, the api and mcp `__version__`, `ui/package.json` +and its lockfile) and adds the release's section to [`CHANGELOG.md`](CHANGELOG.md), written +from the conventional-commit titles of the PRs merged since the last release (`feat:` → minor +while we are below 1.0, `fix:` → patch). A CI check fails if those strings ever disagree with +[`.release-please-manifest.json`](.release-please-manifest.json). Without a +`RELEASE_PLEASE_TOKEN` secret, the release PR is opened with `GITHUB_TOKEN` and its checks do +not start until a maintainer closes and reopens it. + +Merging it changes `version` in +[`charts/nebari-apps/Chart.yaml`](charts/nebari-apps/Chart.yaml), which runs +[`release.yaml`](.github/workflows/release.yaml). That uses the org's `pack-release` workflow to: - pin the chart's four image tags to `sha-` (those images are built by the @@ -308,6 +324,10 @@ helm install nebari-apps oci://ghcr.io/nebari-dev/apps-pack/charts/nebari-apps \ --set keycloak.url=https://keycloak./auth ``` +A running install reports its version and image build at `GET /api/v1/version` +(`{"version": "0.2.0", "build": "sha-abc1234"}`), and the UI shows it at the bottom of the +account menu. Include it in bug reports. + To check an image's or chart's signature, see [Verifying Nebari pack artifacts](https://github.com/nebari-dev/.github/blob/main/verifying-nebari-artifacts.md). The signer identity is the org workflow at the commit this repo pins diff --git a/api/pyproject.toml b/api/pyproject.toml index 304eb60..060bea0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nebari-apps-api" -version = "0.1.0" +dynamic = ["version"] description = "REST API for the Nebari Apps Pack - CRUD + observability over App custom resources" requires-python = ">=3.12" dependencies = [ @@ -26,6 +26,11 @@ dev = [ requires = ["hatchling"] build-backend = "hatchling.build" +# The version lives in __init__.py (release-please bumps it), so uv.lock +# does not record it and a release needs no relock. +[tool.hatch.version] +path = "src/nebari_apps_api/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/nebari_apps_api"] diff --git a/api/src/nebari_apps_api/__init__.py b/api/src/nebari_apps_api/__init__.py index 6278ce7..9b2e131 100644 --- a/api/src/nebari_apps_api/__init__.py +++ b/api/src/nebari_apps_api/__init__.py @@ -1,3 +1,3 @@ """nebari-apps-api - REST API for the Nebari Apps Pack.""" -__version__ = "0.1.0" +__version__ = "0.1.0" # x-release-please-version diff --git a/api/src/nebari_apps_api/config.py b/api/src/nebari_apps_api/config.py index f598d5b..d32265f 100644 --- a/api/src/nebari_apps_api/config.py +++ b/api/src/nebari_apps_api/config.py @@ -68,6 +68,10 @@ class Settings: ui_keycloak_realm: str = field(default_factory=lambda: os.environ.get("UI_KEYCLOAK_REALM", "nebari")) ui_keycloak_client_id: str = field(default_factory=lambda: os.environ.get("UI_KEYCLOAK_CLIENT_ID", "")) + # The image this API runs from (the chart passes its image tag, sha- + # for released charts), reported next to the version by /api/v1/version. + build: str = field(default_factory=lambda: os.environ.get("APPS_BUILD", "unknown")) + # Serve apps over plain HTTP (mirrors the operator's --tls-disabled). tls_disabled: bool = field(default_factory=lambda: _env_bool("TLS_DISABLED", False)) diff --git a/api/src/nebari_apps_api/main.py b/api/src/nebari_apps_api/main.py index f0a4bd2..7f01221 100644 --- a/api/src/nebari_apps_api/main.py +++ b/api/src/nebari_apps_api/main.py @@ -15,6 +15,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile from fastapi.responses import JSONResponse +from . import __version__ from . import cr as crmod from .auth import User, can_access, current_user, is_admin from .config import settings @@ -45,7 +46,7 @@ def create_app(store: AppStore | None = None) -> FastAPI: - app = FastAPI(title="nebari-apps-api", version="0.1.0") + app = FastAPI(title="nebari-apps-api", version=__version__) if not settings.auth_enabled: log.warning( "AUTHENTICATION IS DISABLED (AUTH_ENABLED=false): every request is anonymous and " @@ -189,10 +190,17 @@ def readyz(store: Store) -> JSONResponse: ready = all(v == "ok" for v in checks.values()) return JSONResponse({"status": "ok" if ready else "unavailable", "checks": checks}, 200 if ready else 503) + @app.get(PREFIX + "/version") + def version() -> dict[str, str]: + """Public: which release and image this API is, for bug reports.""" + return {"version": __version__, "build": settings.build} + @app.get(PREFIX + "/config") def ui_config() -> dict[str, Any]: """Public bootstrap config for the UI (keycloak-js).""" return { + "version": __version__, + "build": settings.build, "authEnabled": settings.auth_enabled, "keycloak": { "url": settings.ui_keycloak_url, diff --git a/api/tests/test_apps.py b/api/tests/test_apps.py index 29f8bda..eff9148 100644 --- a/api/tests/test_apps.py +++ b/api/tests/test_apps.py @@ -172,6 +172,16 @@ def test_catalogs(client): assert config["authEnabled"] is False +def test_version(client, monkeypatch): + from nebari_apps_api import __version__ + from nebari_apps_api.config import settings + + monkeypatch.setattr(settings, "build", "sha-abc1234") + assert client.get("/api/v1/version").json() == {"version": __version__, "build": "sha-abc1234"} + config = client.get("/api/v1/config").json() + assert (config["version"], config["build"]) == (__version__, "sha-abc1234") + + def test_analytics_summary(client): client.post("/api/v1/apps", json=make_app_body()) body = make_app_body(name="two", namespace="team-a") diff --git a/api/uv.lock b/api/uv.lock index bcb2439..acde41c 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1175,7 +1175,6 @@ wheels = [ [[package]] name = "nebari-apps-api" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, diff --git a/charts/nebari-apps/Chart.yaml b/charts/nebari-apps/Chart.yaml index 8ab4e77..87071c5 100644 --- a/charts/nebari-apps/Chart.yaml +++ b/charts/nebari-apps/Chart.yaml @@ -6,8 +6,8 @@ description: >- apps-operator that reconciles Apps into Deployments, Services, and NebariApps. type: application -version: 0.1.0 -appVersion: "0.1.0" +version: 0.1.0 # x-release-please-version +appVersion: "0.1.0" # x-release-please-version home: https://github.com/nebari-dev/apps-pack sources: - https://github.com/nebari-dev/apps-pack diff --git a/charts/nebari-apps/templates/api.yaml b/charts/nebari-apps/templates/api.yaml index 0101afb..736d101 100644 --- a/charts/nebari-apps/templates/api.yaml +++ b/charts/nebari-apps/templates/api.yaml @@ -142,6 +142,8 @@ spec: value: {{ .Values.api.auth.enabled | quote }} - name: TLS_DISABLED value: {{ not .Values.tls.enabled | quote }} + - name: APPS_BUILD + value: {{ .Values.api.image.tag | default .Chart.AppVersion | quote }} {{- if .Values.api.auth.enabled }} - name: OIDC_ISSUER value: {{ printf "%s/realms/%s" (required "keycloak.url is required when api.auth.enabled" .Values.keycloak.url | trimSuffix "/") .Values.keycloak.realm }} diff --git a/docs/src/content/docs/api-reference.md b/docs/src/content/docs/api-reference.md index cf0dcbb..b4e8d11 100644 --- a/docs/src/content/docs/api-reference.md +++ b/docs/src/content/docs/api-reference.md @@ -10,7 +10,7 @@ both thin clients over this API. ## Authentication -All endpoints except `/healthz`, `/readyz` and `/config` require a Keycloak **JWT bearer token**: +All endpoints except `/healthz`, `/readyz`, `/version` and `/config` require a Keycloak **JWT bearer token**: ``` Authorization: Bearer @@ -44,7 +44,8 @@ create. |---|---| | `GET /healthz` | Liveness (public). Always `200` while the process serves. | | `GET /readyz` | Readiness (public): `200 {"status": "ok", "checks": {...}}` when the Kubernetes API answers a namespace list and, with auth on, the realm JWKS loads (each within 3s; a good JWKS fetch is reused for 60s). Otherwise `503`, with the failing check marked `"failed"` and the reason in the API's log. | -| `GET /config` | UI bootstrap config (public): `authEnabled`, Keycloak url/realm/SPA client id, `appsDomain`, `appsScheme`. | +| `GET /version` | Release and image build (public): `{"version": "0.2.0", "build": "sha-abc1234"}`. `build` is the chart's `api.image.tag` (`unknown` outside the chart). | +| `GET /config` | UI bootstrap config (public): `authEnabled`, Keycloak url/realm/SPA client id, `appsDomain`, `appsScheme`, and the `version`/`build` above. | | `GET /capabilities` | `{appsDomain, sourceTypes, namespaces}` — what this cluster supports and where the caller may launch. `sourceTypes` is `["git", "inline", "pvc"]`. | | `GET /auth/me` | The caller's username, email, groups, and `admin` (member of an admin group). | diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 56bb3cd..d571b13 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -37,10 +37,14 @@ kubectl label namespace apps nebari.dev/managed=true Then install: ```bash +# From a checkout: the chart's default image tags are its appVersion, which +# only matches images at a release commit. On main, use the images main builds. helm install nebari-apps charts/nebari-apps \ --namespace nebari-apps --create-namespace \ --set clusterDomain=example.ai \ - --set keycloak.url=https://keycloak.example.ai/auth + --set keycloak.url=https://keycloak.example.ai/auth \ + --set operator.image.tag=latest --set api.image.tag=latest \ + --set ui.image.tag=latest --set mcp.image.tag=latest # The nebari-operator only processes namespaces that opt in: kubectl label namespace nebari-apps nebari.dev/managed=true diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index fa86720..177177b 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nebari-apps-mcp" -version = "0.1.0" +dynamic = ["version"] description = "MCP server for the Nebari Apps Pack - launch and manage web apps from coding agents" requires-python = ">=3.12" dependencies = [ @@ -28,6 +28,11 @@ dev = [ requires = ["hatchling"] build-backend = "hatchling.build" +# The version lives in __init__.py (release-please bumps it), so uv.lock +# does not record it and a release needs no relock. +[tool.hatch.version] +path = "src/nebari_apps_mcp/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/nebari_apps_mcp"] diff --git a/mcp/src/nebari_apps_mcp/__init__.py b/mcp/src/nebari_apps_mcp/__init__.py index 70969fa..e32898e 100644 --- a/mcp/src/nebari_apps_mcp/__init__.py +++ b/mcp/src/nebari_apps_mcp/__init__.py @@ -1,3 +1,3 @@ """nebari-apps-mcp - agent-facing MCP server for the Nebari Apps Pack.""" -__version__ = "0.1.0" +__version__ = "0.1.0" # x-release-please-version diff --git a/mcp/src/nebari_apps_mcp/server.py b/mcp/src/nebari_apps_mcp/server.py index 84b6887..3a8698a 100644 --- a/mcp/src/nebari_apps_mcp/server.py +++ b/mcp/src/nebari_apps_mcp/server.py @@ -20,6 +20,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response +from . import __version__ from .auth import MissingSessionError, TokenVerificationError, auth, get_validator from .client import ApiClient, ApiError from .config import settings @@ -33,6 +34,7 @@ mcp = FastMCP( name="nebari-apps", + version=__version__, instructions=( "Launch and manage web apps on this Nebari cluster: static sites (HTML/CSS/JS) " "and Python apps launched by a pixi task (pixi_task; the task must serve on " diff --git a/mcp/uv.lock b/mcp/uv.lock index b0a5a5e..8f1da29 100644 --- a/mcp/uv.lock +++ b/mcp/uv.lock @@ -1535,7 +1535,6 @@ wheels = [ [[package]] name = "nebari-apps-mcp" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..93ffdf3 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "bootstrap-sha": "45856542a75d2ac69dab2cbe72dc64a6dadb130d", + "bump-minor-pre-major": true, + "include-component-in-tag": true, + "include-v-in-tag": false, + "pull-request-title-pattern": "chore: release ${version}", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true } + ], + "packages": { + ".": { + "component": "nebari-apps", + "package-name": "nebari-apps", + "extra-files": [ + "charts/nebari-apps/Chart.yaml", + "api/src/nebari_apps_api/__init__.py", + "mcp/src/nebari_apps_mcp/__init__.py", + { "type": "json", "path": "ui/package.json", "jsonpath": "$.version" }, + { "type": "json", "path": "ui/package-lock.json", "jsonpath": "$.version" }, + { "type": "json", "path": "ui/package-lock.json", "jsonpath": "$.packages[''].version" } + ] + } + } +} diff --git a/ui/src/components/header.tsx b/ui/src/components/header.tsx index f84a72d..4c3a663 100644 --- a/ui/src/components/header.tsx +++ b/ui/src/components/header.tsx @@ -30,6 +30,7 @@ import { NavLink, } from "@/components/ui/navigation-menu"; import { isThemeMode, type ThemeMode } from "@/hooks/use-theme-preference"; +import { getConfig } from "@/lib/auth"; import { getBranding } from "@/lib/branding"; import { cn } from "@/lib/utils"; @@ -76,6 +77,7 @@ export function Header({ // the light one, then the built-in dark logo - mirroring nebari-landing. const { logoUrl, logoUrlDark, title } = getBranding(); const brandedLogo = isDarkMode ? (logoUrlDark ?? logoUrl) : logoUrl; + const { version, build } = getConfig(); return ( @@ -166,6 +168,13 @@ export function Header({ ) : null} + + {version ? ( +

+ Apps Pack v{version} + {build && build !== version ? ` (${build})` : null} +

+ ) : null} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c48bcee..a8b2baa 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -144,6 +144,9 @@ export interface UiConfig { keycloak: { url: string; realm: string; clientId: string }; appsDomain: string; appsScheme?: string; + /** Release (e.g. 0.2.0) and image build (e.g. sha-abc1234) of the API. */ + version?: string; + build?: string; } export interface AppEvent {