diff --git a/docs/deploy/public-portal-access-control.md b/docs/deploy/public-portal-access-control.md index d10c325..aecbbbe 100644 --- a/docs/deploy/public-portal-access-control.md +++ b/docs/deploy/public-portal-access-control.md @@ -197,6 +197,11 @@ RESULT_SERVER_PUBLIC_PORTAL_MODE=true Development or internal-only portal services may leave the flag unset unless they intentionally need to preview the public browser surface. +Set `RESULT_SERVER_VERSION` to the deployed public tag when the production +checkout is not expected to sit exactly on a Git tag. If it is unset, the portal +shows the exact tag for `HEAD` when available, otherwise the current Git +description or `development`. + ## Testing Plan Add lightweight tests as the design is implemented: diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..048d45c --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,18 @@ +# Benchkit Release Notes + +## v2026.08.31 - Initial public CX Portal baseline + +Public portal baseline for scoped QWS measurements on Fugaku and RIKYU, with +public-safe result pages and Portal-managed main-branch triggers. + +- Public result browsing, comparison, and system catalog pages are available in + public portal mode. +- Public mode hides operator-only views, raw result JSON routes, trigger + internals, and environment snapshot detail. +- Portal-managed triggers submit scoped main-branch measurements with an + explicit result-server destination. +- Build cache restore checks source identity, host build environment, and + restored artifact integrity. +- Branch and tag source inputs record the resolved commit used for the build. +- Manual GitLab CI is reserved for development and release-candidate validation, + not production main results. diff --git a/result_server/app.py b/result_server/app.py index 4fc9a81..3d481c9 100644 --- a/result_server/app.py +++ b/result_server/app.py @@ -16,6 +16,7 @@ from utils.auth import parse_ingest_keys from utils.csrf import init_csrf from utils.portal_access import is_public_portal_mode, register_public_portal_guard +from utils.portal_version import portal_version_info from utils.preflight import validate_production_config @@ -194,6 +195,7 @@ def create_app(prefix="", base_dir=None): _configure_api_auth(app) _configure_public_portal_mode(app) _configure_execution_profiles(app, base_dir) + app.config["PORTAL_VERSION"] = portal_version_info() register_public_portal_guard(app) init_csrf(app, exempt_blueprints=(api_bp,)) diff --git a/result_server/app_dev.py b/result_server/app_dev.py index 0ce3f68..c7db175 100644 --- a/result_server/app_dev.py +++ b/result_server/app_dev.py @@ -169,6 +169,7 @@ def create_dev_app(base_dir): from utils.audit_logging import configure_audit_logging from utils.auth import parse_ingest_keys from utils.csrf import init_csrf + from utils.portal_version import portal_version_info from utils.system_info import get_all_systems_info, summarize_systems_info app = Flask(__name__, template_folder="templates") @@ -202,6 +203,7 @@ def create_dev_app(base_dir): ALLOWED_AFFILIATIONS=parse_allowed_affiliations( os.environ.get("RESULT_SERVER_ALLOWED_AFFILIATIONS") ), + PORTAL_VERSION=portal_version_info(), ) Session(app) configure_audit_logging(app) diff --git a/result_server/routes/home.py b/result_server/routes/home.py index d39bff7..51acddd 100644 --- a/result_server/routes/home.py +++ b/result_server/routes/home.py @@ -2,6 +2,8 @@ from flask import render_template +from utils.portal_version import portal_release_notes + HOME_GUIDE_LINKS = { "add_app": "https://github.com/RIKEN-RCCS/benchkit/blob/main/docs/guides/add-app.md", @@ -23,4 +25,16 @@ def homepage(): guide_links=build_home_guide_links(), ) + def changes(): + return render_template( + "changes.html", + release_notes=portal_release_notes(), + ) + app.add_url_rule(f"{prefix}/", endpoint="home", view_func=homepage, strict_slashes=False) + app.add_url_rule( + f"{prefix}/changes", + endpoint="changes", + view_func=changes, + strict_slashes=False, + ) diff --git a/result_server/templates/_navigation.html b/result_server/templates/_navigation.html index 97c8fb6..dfc8eab 100644 --- a/result_server/templates/_navigation.html +++ b/result_server/templates/_navigation.html @@ -104,6 +104,7 @@ CX Portal Home Systems + Changes {% if session.get('authenticated') and not public_portal_mode %} Public diff --git a/result_server/templates/_results_base.html b/result_server/templates/_results_base.html index 16852c4..cf3fc9f 100644 --- a/result_server/templates/_results_base.html +++ b/result_server/templates/_results_base.html @@ -24,6 +24,17 @@

{% block page_title %}{{ self.title() }}{% endblock %} {% block content %}{% endblock %} + + {% set portal_version = config.get('PORTAL_VERSION', {}) %} + {% set portal_version_label = portal_version.get('label', 'development') %} + {% set portal_version_commit = portal_version.get('commit', '') %} + diff --git a/result_server/templates/_table_base.html b/result_server/templates/_table_base.html index b302179..fa5b083 100644 --- a/result_server/templates/_table_base.html +++ b/result_server/templates/_table_base.html @@ -61,6 +61,25 @@ background: rgba(255, 255, 255, 0.94); box-shadow: 0 12px 30px rgba(18, 52, 77, 0.06); } + .portal-footer { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin: 18px 0 0; + padding: 12px 2px 0; + border-top: 1px solid #d8e3e8; + color: #52606d; + font-size: 12px; + } + .portal-footer a { + font-weight: 600; + text-decoration: none; + } + .portal-version-label, + .portal-version-commit { + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + } .table-card { overflow: hidden; } @@ -346,6 +365,9 @@ .page-subtitle { font-size: 13px; } + .portal-footer { + gap: 8px; + } } diff --git a/result_server/templates/changes.html b/result_server/templates/changes.html new file mode 100644 index 0000000..f493a1e --- /dev/null +++ b/result_server/templates/changes.html @@ -0,0 +1,76 @@ +{% extends "_results_base.html" %} + +{% block title %}CX Portal Changes{% endblock %} +{% block page_title %}CX Portal Changes{% endblock %} +{% block page_subtitle %}Broad-grained release notes for the public CX Portal surface.{% endblock %} + +{% block content %} + + +
+ {% for release in release_notes %} +
+
+

+ {{ release.version }}{{ release.title }} +

+ {{ release.date }} +
+

{{ release.summary }}

+
    + {% for item in release.changes %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/result_server/test_support.py b/result_server/test_support.py index 7a22188..76b18fb 100644 --- a/result_server/test_support.py +++ b/result_server/test_support.py @@ -139,6 +139,10 @@ def profile_requests(): def home(): return "" + @app.route(f"{prefix}/changes") + def changes(): + return "" + if include_systemlist_route: def systemlist(): return "" diff --git a/result_server/tests/test_environment_snapshot_results_route.py b/result_server/tests/test_environment_snapshot_results_route.py index 2e5c2b8..9948e28 100644 --- a/result_server/tests/test_environment_snapshot_results_route.py +++ b/result_server/tests/test_environment_snapshot_results_route.py @@ -16,6 +16,7 @@ def _add_navigation_routes(app): app.add_url_rule("/", "home", lambda: "home") + app.add_url_rule("/changes", "changes", lambda: "changes") app.add_url_rule("/systems", "systemlist", lambda: "systems") app.add_url_rule("/login", "auth.login", lambda: "login") app.add_url_rule("/logout", "auth.logout", lambda: "logout") diff --git a/result_server/tests/test_home_template.py b/result_server/tests/test_home_template.py index 11d4cba..0b539ae 100644 --- a/result_server/tests/test_home_template.py +++ b/result_server/tests/test_home_template.py @@ -64,3 +64,22 @@ def test_home_page_renders_discord_link_when_configured(monkeypatch): assert "invitation-only Discord" in html assert "application-onboarding coordination" in html assert "https://discord.gg/example" in html + + +def test_changes_page_renders_release_notes(monkeypatch): + monkeypatch.delenv("CX_DISCORD_INVITE_URL", raising=False) + app = build_portal_shell_app( + templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"), + include_home_route=False, + ) + register_home_routes(app) + + with app.test_client() as client: + response = client.get("/changes") + + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert "CX Portal Changes" in html + assert "v2026.08.31" in html + assert "Initial public CX Portal baseline" in html + assert "Portal-managed triggers" in html diff --git a/result_server/tests/test_portal_access_policy.py b/result_server/tests/test_portal_access_policy.py index 4b27744..f222585 100644 --- a/result_server/tests/test_portal_access_policy.py +++ b/result_server/tests/test_portal_access_policy.py @@ -53,6 +53,7 @@ def test_every_registered_route_has_access_class(tmp_path): def test_representative_route_access_classes(): + assert classify_endpoint("changes") == ACCESS_PUBLIC assert classify_endpoint("home") == ACCESS_PUBLIC assert classify_endpoint("systemlist") == ACCESS_PUBLIC assert classify_endpoint("results.results") == ACCESS_PUBLIC @@ -74,6 +75,7 @@ def test_public_portal_mode_blocks_restricted_browser_routes_but_allows_api_auth with app.test_client() as client: assert client.get("/").status_code == 200 + assert client.get("/changes").status_code == 200 assert client.get("/auth/login").status_code == 404 assert client.get("/estimated/").status_code == 404 assert client.get("/results/confidential").status_code == 404 @@ -98,6 +100,7 @@ def test_public_portal_mode_hides_anonymous_restricted_navigation(): assert "Home" in html assert "Systems" in html + assert "Changes" in html assert "Results" in html assert "Login" not in html assert "Admin" not in html @@ -136,6 +139,7 @@ def test_public_portal_mode_hides_authenticated_restricted_navigation(): assert "Home" in html assert "Systems" in html + assert "Changes" in html assert "Results" in html assert "admin@example.test" not in html assert "Login" not in html diff --git a/result_server/tests/test_portal_version.py b/result_server/tests/test_portal_version.py new file mode 100644 index 0000000..d32de33 --- /dev/null +++ b/result_server/tests/test_portal_version.py @@ -0,0 +1,59 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils import portal_version + + +def test_portal_version_prefers_environment(monkeypatch, tmp_path): + monkeypatch.setenv("RESULT_SERVER_VERSION", "vtest") + monkeypatch.setattr(portal_version, "_find_git_root", lambda start_path: tmp_path) + monkeypatch.setattr( + portal_version, + "_run_git", + lambda args, git_root: "abc123def456" if args[:1] == ["rev-parse"] else "vold", + ) + + info = portal_version.portal_version_info() + + assert info["label"] == "vtest" + assert info["commit"] == "abc123def456" + assert info["source"] == "environment" + + +def test_portal_version_uses_exact_git_tag(monkeypatch, tmp_path): + monkeypatch.delenv("RESULT_SERVER_VERSION", raising=False) + monkeypatch.delenv("BENCHKIT_PORTAL_VERSION", raising=False) + monkeypatch.setattr(portal_version, "_find_git_root", lambda start_path: tmp_path) + + def fake_run_git(args, git_root): + if args[:1] == ["rev-parse"]: + return "123456789abc" + if args == ["describe", "--tags", "--exact-match", "HEAD"]: + return "v2026.08.31" + return "v2026.08.31-1-g1234567" + + monkeypatch.setattr(portal_version, "_run_git", fake_run_git) + + info = portal_version.portal_version_info() + + assert info == { + "label": "v2026.08.31", + "commit": "123456789abc", + "source": "git", + } + + +def test_portal_version_falls_back_to_development(monkeypatch): + monkeypatch.delenv("RESULT_SERVER_VERSION", raising=False) + monkeypatch.delenv("BENCHKIT_PORTAL_VERSION", raising=False) + monkeypatch.setattr(portal_version, "_find_git_root", lambda start_path: None) + + info = portal_version.portal_version_info() + + assert info == { + "label": "development", + "commit": "", + "source": "default", + } diff --git a/result_server/tests/test_public_result_routes.py b/result_server/tests/test_public_result_routes.py index e3dd6ae..f8eabef 100644 --- a/result_server/tests/test_public_result_routes.py +++ b/result_server/tests/test_public_result_routes.py @@ -16,6 +16,7 @@ def _add_navigation_routes(app): app.add_url_rule("/", "home", lambda: "home") + app.add_url_rule("/changes", "changes", lambda: "changes") app.add_url_rule("/systems", "systemlist", lambda: "systems") app.add_url_rule("/login", "auth.login", lambda: "login") app.add_url_rule("/logout", "auth.logout", lambda: "logout") diff --git a/result_server/utils/portal_access.py b/result_server/utils/portal_access.py index af9ab3e..af72efe 100644 --- a/result_server/utils/portal_access.py +++ b/result_server/utils/portal_access.py @@ -23,6 +23,7 @@ PUBLIC_ENDPOINTS = frozenset( { + "changes", "home", "systemlist", "static", diff --git a/result_server/utils/portal_version.py b/result_server/utils/portal_version.py new file mode 100644 index 0000000..92f3d98 --- /dev/null +++ b/result_server/utils/portal_version.py @@ -0,0 +1,99 @@ +"""Portal release/version helpers.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +RELEASE_NOTES = [ + { + "version": "v2026.08.31", + "date": "2026-08-31", + "title": "Initial public CX Portal baseline", + "summary": ( + "Public portal baseline for scoped QWS measurements on Fugaku and RIKYU, " + "with public-safe result pages and Portal-managed main-branch triggers." + ), + "changes": [ + "Public result browsing, comparison, and system catalog pages are available in public portal mode.", + "Public mode hides operator-only views, raw result JSON routes, trigger internals, and environment snapshot detail.", + "Portal-managed triggers submit scoped main-branch measurements with an explicit result-server destination.", + "Build cache restore checks source identity, host build environment, and restored artifact integrity.", + "Branch and tag source inputs record the resolved commit used for the build.", + "Manual GitLab CI is reserved for development and release-candidate validation, not production main results.", + ], + } +] + + +def _find_git_root(start_path: Path) -> Path | None: + current = start_path.resolve() + if current.is_file(): + current = current.parent + for path in (current, *current.parents): + if (path / ".git").exists(): + return path + return None + + +def _run_git(args: list[str], git_root: Path) -> str: + try: + completed = subprocess.run( + ["git", *args], + cwd=git_root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return "" + if completed.returncode != 0: + return "" + return completed.stdout.strip() + + +def portal_version_info( + env: dict[str, str] | None = None, + *, + start_path: str | os.PathLike[str] | None = None, +) -> dict[str, str]: + """Return the release label and source commit displayed by the portal.""" + source = env if env is not None else os.environ + explicit_version = ( + source.get("RESULT_SERVER_VERSION", "").strip() + or source.get("BENCHKIT_PORTAL_VERSION", "").strip() + ) + + git_root = _find_git_root(Path(start_path or __file__)) + commit = "" + git_label = "" + if git_root is not None: + commit = _run_git(["rev-parse", "--short=12", "HEAD"], git_root) + git_label = _run_git(["describe", "--tags", "--exact-match", "HEAD"], git_root) + if not git_label: + git_label = _run_git(["describe", "--tags", "--always", "--dirty"], git_root) + + if explicit_version: + label = explicit_version + source_name = "environment" + elif git_label: + label = git_label + source_name = "git" + else: + label = "development" + source_name = "default" + + return { + "label": label, + "commit": commit, + "source": source_name, + } + + +def portal_release_notes() -> list[dict[str, object]]: + """Return broad-grained release notes for the public changes page.""" + return RELEASE_NOTES