diff --git a/.cursor/commands/create-learning-path/README.md b/.cursor/commands/create-learning-path/README.md index 3c58e894a..ecc56eac3 100644 --- a/.cursor/commands/create-learning-path/README.md +++ b/.cursor/commands/create-learning-path/README.md @@ -22,8 +22,8 @@ Follow these phases in order: 2. **Read feature docs.** Identify the canonical Grafana docs pages for the feature. Read every doc page in full from the local `website` repo first, then WebFetch. 3. **Propose path options.** Review existing paths in `interactive-tutorials/[slug]-lj` for structural patterns. Propose 2-4 path options with milestones. Target 2-5 minutes per milestone, 6-8 milestones per path (max 10). Wait for user approval before proceeding. 4. **Scaffold content files.** Create `content.json` for every milestone — interactive blocks for UI steps, markdown blocks for conceptual content. -5. **Create website metadata files.** Create `website.yaml` for the path and each milestone. Refer to `docs/website-yaml-reference.md`. -6. **Generate manifests.** Create `manifest.json` for the path (`type: "path"`, milestones array, targeting) and each milestone (`type: "guide"`, depends/recommends chain). Refer to `docs/manifest-reference.md`. Where fields can't be derived, ask the user to provide values before generating. +5. **Create website metadata files.** Create `website.yaml` for the path and each milestone. Every file needs a non-empty `description` and must end with a trailing newline. Refer to `docs/website-yaml-reference.md`. +6. **Generate manifests.** Create `manifest.json` for the path (`type: "path"`, milestones array, targeting) and each milestone (`type: "guide"`, depends/recommends chain). Refer to `docs/manifest-reference.md`. Put framing / concept-intro packages on disk for the website but **omit** them from path `milestones`; first hands-on uses `"depends": []`. Where fields can't be derived, ask the user to provide values before generating. Validate with `python3 .github/scripts/validate_learning_path_packages.py --path -lj` before opening the PR. 7. **Discover selectors.** Use Playwright at `learn.grafana.net` to find stable CSS selectors for each interactive element. The user must log in through the Playwright browser window (Okta SAML). 8. **Test in Pathfinder.** Tell the user which `content.json` to import into the Block Editor at `learn.grafana.net/?pathfinder-dev=true`. Wait for their feedback on each "Show me" / "Do it" button. Fix broken selectors based on their reports. 9. **Verify and wrap up.** Cross-check all factual claims against live docs. Update `.github/CODEOWNERS`. Provide a summary of all files created. diff --git a/.cursor/skills/review-learning-path/reference-checks.md b/.cursor/skills/review-learning-path/reference-checks.md index 4c8d05c75..8f447c7b9 100644 --- a/.cursor/skills/review-learning-path/reference-checks.md +++ b/.cursor/skills/review-learning-path/reference-checks.md @@ -84,7 +84,9 @@ Also apply [review-guide-pr.mdc](../../review-guide-pr.mdc) blocking rules. Framing packages may exist in the repo for the website Learning Path but must **not** appear in path `manifest.json` `milestones`. -**Common framing:** `business-value`, `advantages`, `welcome`, markdown-only intro milestones. +**Common framing:** `business-value`, `advantages`, `welcome`, `understand-value` / `understand-baselines`, `value-*` / `value-of-*`, `how-it-works`, markdown-only intro milestones. + +**Not framing by prefix alone:** product teach steps such as `understand-alerts` or `understand-dashboards` stay in path `milestones`. **Flag when:** @@ -94,6 +96,8 @@ Framing packages may exist in the repo for the website Learning Path but must ** **OK:** Framing directories + `website.yaml` remain; `end-journey` and hands-on milestones stay in path `milestones`. +**CI:** `.github/scripts/validate_learning_path_packages.py` (wired in `.github/workflows/validate-json.yml`) enforces framing-out-of-milestones, first-hands-on depends, required `website.yaml` `description`, and trailing newlines on `website.yaml`. Prefer that script over re-deriving patterns during Phase 2. + --- ## Path root `content.json` @@ -122,6 +126,8 @@ Path root `{path_dir}/website.yaml` configures the companion Learning Path on gr |---|---| | Path root file | Missing when path has framing milestone dirs (`business-value`, etc.) or website companion slug exists | | `menuTitle` / `description` | Missing or empty on path root `website.yaml` | +| Step `description` | Missing or empty on any step `website.yaml` present under the package | +| Trailing newline | Any `website.yaml` missing a final `\n` (breaks deploy-preview Hugo front matter) | | `journey` metadata | Missing `group`, `skill`, or layout fields peer LPs include | | Slug alignment | Website path slug ≠ `{path_dir}` minus `-lj` when companion exists | | Milestone pages | Milestone dir in path `milestones` missing `website.yaml` when peer LPs include one for same step type | diff --git a/.github/scripts/validate_learning_path_packages.py b/.github/scripts/validate_learning_path_packages.py new file mode 100644 index 000000000..037b147ff --- /dev/null +++ b/.github/scripts/validate_learning_path_packages.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Validate Pathfinder learning-path packaging conventions used by Learning Hub. + +These checks catch review findings that Pathfinder CLI validate does not cover: + +1. Framing packages must not appear in path manifest ``milestones``. +2. The first hands-on milestone must not ``depends`` on a framing package + (use ``"depends": []``). +3. Path-level and step-level ``website.yaml`` files require a non-empty + ``description`` (Learning Hub meta / listings). +4. Every ``website.yaml`` must end with a trailing newline (deploy-preview + concatenates ``type: docs`` after ``cat``; a missing newline merges lines + and breaks Hugo YAML unmarshalling). + +Framing packages may still exist on disk for the website companion path; they +must simply be omitted from Pathfinder path ``milestones``. + +Usage: + python3 .github/scripts/validate_learning_path_packages.py + python3 .github/scripts/validate_learning_path_packages.py --path run-first-k6-test-lj +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Iterable + + +# Directory / short-name patterns for website-only framing milestones. +# Keep in sync with .cursor/skills/review-learning-path/reference-checks.md +# +# Do NOT treat every "understand-*" milestone as framing. Product teach steps +# like understand-alerts / understand-dashboards stay in path milestones. +# Only known concept-intro short names (and shared value/advantages prefixes) +# are framing. +FRAMING_EXACT = frozenset( + { + "advantages", + "business-value", + "build-mental-model", + "how-it-works", + "understand-baselines", + "understand-value", + "welcome", + } +) + +FRAMING_PREFIXES = ( + "advantages-", + "business-value-", + "build-mental-model-", + "how-it-works-", + "value-", + "value-of-", + "welcome-", +) + + +class Finding: + def __init__(self, path: Path, message: str) -> None: + self.path = path + self.message = message + + def format(self) -> str: + return f"{self.path}: {self.message}" + + +def path_slug(path_id: str) -> str: + return path_id[:-3] if path_id.endswith("-lj") else path_id + + +def milestone_short_name(path_id: str, milestone_id: str) -> str: + prefix = f"{path_slug(path_id)}-" + if milestone_id.startswith(prefix): + return milestone_id[len(prefix) :] + return milestone_id + + +def is_framing_short_name(short_name: str) -> bool: + if short_name in FRAMING_EXACT: + return True + return any(short_name.startswith(prefix) for prefix in FRAMING_PREFIXES) + + +def is_framing_milestone_id(path_id: str, milestone_id: str) -> bool: + return is_framing_short_name(milestone_short_name(path_id, milestone_id)) + + +def iter_path_packages(root: Path) -> Iterable[Path]: + for manifest in sorted(root.glob("*-lj/manifest.json")): + yield manifest.parent + + +def load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"{path}: root must be a JSON object") + return data + + +def top_level_description(text: str) -> str | None: + """Return the top-level YAML ``description`` value, or None if missing. + + Avoids a PyYAML dependency. Supports single-line and simple folded + continuations used in Learning Hub website.yaml files. + """ + lines = text.splitlines() + for i, line in enumerate(lines): + if not line.startswith("description:"): + continue + value = line[len("description:") :].strip() + if value in {"|", ">", "|-", ">-", ""}: + parts: list[str] = [] + if value not in {"|", ">", "|-", ">-", ""}: + parts.append(value) + for cont in lines[i + 1 :]: + if cont.startswith((" ", "\t")): + parts.append(cont.strip()) + elif cont.strip() == "": + continue + else: + break + joined = " ".join(parts).strip() + return joined or None + return value.strip().strip("\"'") + return None + + +def find_guide_dir(path_dir: Path, guide_id: str) -> Path | None: + for child in path_dir.iterdir(): + if not child.is_dir(): + continue + manifest = child / "manifest.json" + if not manifest.is_file(): + continue + try: + data = load_json(manifest) + except (OSError, ValueError, json.JSONDecodeError): + continue + if data.get("id") == guide_id: + return child + return None + + +def validate_path_package(path_dir: Path) -> list[Finding]: + findings: list[Finding] = [] + path_manifest = path_dir / "manifest.json" + try: + path_data = load_json(path_manifest) + except (OSError, ValueError, json.JSONDecodeError) as err: + return [Finding(path_manifest, f"unable to read path manifest ({err})")] + + if path_data.get("type") != "path": + return findings + + path_id = path_data.get("id") + if not isinstance(path_id, str) or not path_id: + findings.append(Finding(path_manifest, 'path manifest missing string "id"')) + return findings + + milestones = path_data.get("milestones") + if not isinstance(milestones, list) or not milestones: + findings.append( + Finding(path_manifest, 'path manifest missing non-empty "milestones" array') + ) + return findings + + for milestone_id in milestones: + if not isinstance(milestone_id, str): + findings.append( + Finding(path_manifest, f"milestone entry must be a string: {milestone_id!r}") + ) + continue + if is_framing_milestone_id(path_id, milestone_id): + short = milestone_short_name(path_id, milestone_id) + findings.append( + Finding( + path_manifest, + f'framing milestone "{milestone_id}" (short name "{short}") must not ' + f"appear in path milestones — keep the package directory for the website " + f"but omit it from Pathfinder milestones", + ) + ) + + first_id = milestones[0] + if isinstance(first_id, str): + first_dir = find_guide_dir(path_dir, first_id) + if first_dir is None: + findings.append( + Finding( + path_manifest, + f'first milestone "{first_id}" has no matching guide directory/manifest', + ) + ) + else: + first_manifest = first_dir / "manifest.json" + try: + first_data = load_json(first_manifest) + except (OSError, ValueError, json.JSONDecodeError) as err: + findings.append( + Finding(first_manifest, f"unable to read first hands-on manifest ({err})") + ) + else: + depends = first_data.get("depends") or [] + if not isinstance(depends, list): + findings.append( + Finding(first_manifest, '"depends" must be an array') + ) + else: + for dep in depends: + if isinstance(dep, str) and is_framing_milestone_id(path_id, dep): + findings.append( + Finding( + first_manifest, + f'first hands-on milestone depends on framing package ' + f'"{dep}" — use "depends": []', + ) + ) + + website_files: list[Path] = [] + path_website = path_dir / "website.yaml" + if path_website.is_file(): + website_files.append(path_website) + for child in sorted(path_dir.iterdir()): + if not child.is_dir() or child.name.startswith(".") or child.name == "assets": + continue + step_website = child / "website.yaml" + if step_website.is_file(): + website_files.append(step_website) + + for website in website_files: + try: + raw = website.read_bytes() + text = raw.decode("utf-8") + except OSError as err: + findings.append(Finding(website, f"unable to read website.yaml ({err})")) + continue + except UnicodeDecodeError as err: + findings.append(Finding(website, f"website.yaml is not valid UTF-8 ({err})")) + continue + if raw and not raw.endswith(b"\n"): + findings.append( + Finding( + website, + "must end with a trailing newline — deploy-preview cats this " + "file then appends type/title/description; a missing newline " + "merges the last line with 'type: docs' and breaks Hugo YAML", + ) + ) + description = top_level_description(text) + if not description: + findings.append( + Finding( + website, + 'missing required non-empty top-level "description" ' + "(Learning Hub meta / listings)", + ) + ) + + return findings + + +def validate_repo(root: Path, only: str | None = None) -> list[Finding]: + findings: list[Finding] = [] + packages = list(iter_path_packages(root)) + if only: + packages = [p for p in packages if p.name == only] + if not packages: + return [Finding(root / only, "learning path directory not found")] + for path_dir in packages: + findings.extend(validate_path_package(path_dir)) + return findings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Validate learning-path framing, depends, website.yaml description, " + "and website.yaml trailing newlines." + ) + ) + parser.add_argument( + "--root", + type=Path, + default=Path("."), + help="Repository root (default: current directory)", + ) + parser.add_argument( + "--path", + dest="only", + help="Validate a single *-lj package directory name", + ) + args = parser.parse_args(argv) + + root = args.root.resolve() + findings = validate_repo(root, only=args.only) + if not findings: + scope = args.only or "all *-lj packages" + print(f"✅ Learning path packaging checks passed ({scope})") + return 0 + + print("❌ Learning path packaging check failures:") + print("") + for finding in findings: + rel = finding.path + try: + rel = finding.path.relative_to(root) + except ValueError: + pass + print(f" - {rel}: {finding.message}") + print(f"::error file={rel}::{finding.message}") + print("") + print(f"{len(findings)} issue(s) found.") + print( + "See docs/website-yaml-reference.md and " + ".cursor/skills/review-learning-path/reference-checks.md (Framing milestones)." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a43c847f..0bfdd8c94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,7 +63,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '22' + # pathfinder-app engines.node is >=24 (see validate-json.yml). + node-version: '24' cache: 'npm' cache-dependency-path: pathfinder-app/package-lock.json @@ -141,7 +142,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '22' + # pathfinder-app engines.node is >=24 (see validate-json.yml). + node-version: '24' cache: 'npm' cache-dependency-path: pathfinder-app/package-lock.json diff --git a/.github/workflows/validate-json.yml b/.github/workflows/validate-json.yml index ff8dc2663..a03f23c30 100644 --- a/.github/workflows/validate-json.yml +++ b/.github/workflows/validate-json.yml @@ -73,7 +73,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '22' + # pathfinder-app engines.node is >=24; Node 22 npm ci fails with + # "Missing: typescript@5.9.3 from lock file" against current main lockfile. + node-version: '24' cache: 'npm' cache-dependency-path: pathfinder-app/package-lock.json @@ -206,6 +208,16 @@ jobs: echo "✅ All packages have targeting defined" fi + # Learning Hub packaging conventions Pathfinder CLI does not cover: + # framing milestones must stay out of path milestones, first hands-on must + # not depend on framing, website.yaml needs a non-empty description, and + # website.yaml must end with a trailing newline (deploy-preview concat). + # Source of truth: .github/scripts/validate_learning_path_packages.py + # (docs: docs/website-yaml-reference.md; review skill Phase 2 checks). + - name: Validate learning path packaging conventions + run: | + python3 .github/scripts/validate_learning_path_packages.py + # NOTE: validate --packages does not support --exclude, so we enumerate # package directories from manifest.json files and validate each directory. - name: Validate Pathfinder Packages diff --git a/docs/website-yaml-reference.md b/docs/website-yaml-reference.md index 3d93eb766..e394cd1cb 100644 --- a/docs/website-yaml-reference.md +++ b/docs/website-yaml-reference.md @@ -129,6 +129,26 @@ That would require: A step's analogous block is `side_journeys`. - Path-level `weight` values are spaced (for example, 100, 200, 300) so paths can be reordered without renumbering. - `description` should be a single sentence and avoid duplicating `menuTitle`. +- Every path-level and step-level `website.yaml` **must** include a non-empty top-level `description`. CI enforces this via `.github/scripts/validate_learning_path_packages.py`. +- Every `website.yaml` **must** end with a trailing newline. The deploy-preview build cats the file then appends `type` / `title` / `description`; a missing newline merges the last line with `type: docs` and breaks Hugo YAML. CI enforces this in the same script. + +## CI packaging checks + +Pathfinder `validate` checks guide JSON. It does **not** enforce Learning Hub packaging rules that reviewers otherwise catch late. Run locally before opening an LP PR: + +```bash +python3 .github/scripts/validate_learning_path_packages.py +python3 .github/scripts/validate_learning_path_packages.py --path run-first-k6-test-lj +``` + +The script fails when: + +1. A framing / concept-intro package appears in path `manifest.json` `milestones` (for example `business-value`, `advantages`, `understand-value`). Keep the directory for the website; omit it from Pathfinder milestones. +2. The first hands-on milestone `depends` on a framing package — use `"depends": []`. +3. Path or step `website.yaml` is missing a non-empty `description`. +4. Path or step `website.yaml` is missing a trailing newline. + +Framing short-name patterns live in the script and in [`.cursor/skills/review-learning-path/reference-checks.md`](../.cursor/skills/review-learning-path/reference-checks.md) (Framing milestones). ## Minimal examples diff --git a/monitor-azure-resources-lj/advantages/website.yaml b/monitor-azure-resources-lj/advantages/website.yaml index e6921edce..26b4b6126 100644 --- a/monitor-azure-resources-lj/advantages/website.yaml +++ b/monitor-azure-resources-lj/advantages/website.yaml @@ -10,3 +10,4 @@ keywords: - monitoring advantages - serverless observability - pre-built dashboards +description: Learn the specific benefits of using Grafana Cloud Provider Observability to monitor your Azure infrastructure and services. diff --git a/monitor-azure-resources-lj/business-value/website.yaml b/monitor-azure-resources-lj/business-value/website.yaml index d8384eb7b..691bc92e9 100644 --- a/monitor-azure-resources-lj/business-value/website.yaml +++ b/monitor-azure-resources-lj/business-value/website.yaml @@ -8,6 +8,7 @@ keywords: - Grafana Labs - observability - monitoring +description: Learn about observability and how it can help you solve real-world problems. side_journeys: title: More to explore (optional) heading: 'At this point in your journey, you can explore the following paths:' diff --git a/monitor-azure-resources-lj/configure-metrics/website.yaml b/monitor-azure-resources-lj/configure-metrics/website.yaml index 37d95643a..e87420120 100644 --- a/monitor-azure-resources-lj/configure-metrics/website.yaml +++ b/monitor-azure-resources-lj/configure-metrics/website.yaml @@ -10,6 +10,7 @@ keywords: - Azure resources - configuration - tags +description: Learn how to configure automatic metric discovery and collection for your Azure resources. side_journeys: title: More to explore (optional) heading: 'At this point in your journey, you can explore the following paths:' diff --git a/monitor-azure-resources-lj/connect-azure-account/website.yaml b/monitor-azure-resources-lj/connect-azure-account/website.yaml index 52f5b9562..ed3f4d183 100644 --- a/monitor-azure-resources-lj/connect-azure-account/website.yaml +++ b/monitor-azure-resources-lj/connect-azure-account/website.yaml @@ -10,3 +10,4 @@ keywords: - authentication - account setup - integration +description: Configure the connection between your Azure account and Grafana Cloud using the service principal you created. diff --git a/monitor-azure-resources-lj/create-service-principal/website.yaml b/monitor-azure-resources-lj/create-service-principal/website.yaml index 7205753ed..23ff16f70 100644 --- a/monitor-azure-resources-lj/create-service-principal/website.yaml +++ b/monitor-azure-resources-lj/create-service-principal/website.yaml @@ -10,3 +10,4 @@ keywords: - permissions - Azure Monitor access - security +description: Learn how to create the required Azure service principal to allow Grafana Cloud to access your Azure metrics. diff --git a/monitor-azure-resources-lj/end-journey/website.yaml b/monitor-azure-resources-lj/end-journey/website.yaml index c13d6f4ee..cbaf7fa19 100644 --- a/monitor-azure-resources-lj/end-journey/website.yaml +++ b/monitor-azure-resources-lj/end-journey/website.yaml @@ -10,3 +10,4 @@ keywords: - next steps - additional resources - observability journey +description: Congratulations on completing the Azure monitoring learning path. Explore next steps and additional resources. diff --git a/monitor-azure-resources-lj/explore-data/website.yaml b/monitor-azure-resources-lj/explore-data/website.yaml index 52320d624..aa440fb27 100644 --- a/monitor-azure-resources-lj/explore-data/website.yaml +++ b/monitor-azure-resources-lj/explore-data/website.yaml @@ -10,3 +10,4 @@ keywords: - data analysis - monitoring insights - performance metrics +description: Learn how to navigate and analyze your Azure metrics using Grafana dashboards and exploration tools. diff --git a/monitor-azure-resources-lj/install-dashboards-and-alerts/website.yaml b/monitor-azure-resources-lj/install-dashboards-and-alerts/website.yaml index 9f69fed01..d388ddc3a 100644 --- a/monitor-azure-resources-lj/install-dashboards-and-alerts/website.yaml +++ b/monitor-azure-resources-lj/install-dashboards-and-alerts/website.yaml @@ -10,3 +10,4 @@ keywords: - dashboard - alerts - monitoring +description: Learn about the Azure preconfigured dashboards and alerts and how to use them to troubleshoot your environment. diff --git a/monitor-azure-resources-lj/navigate-azure-config/website.yaml b/monitor-azure-resources-lj/navigate-azure-config/website.yaml index d88cf08e3..9b4d31da3 100644 --- a/monitor-azure-resources-lj/navigate-azure-config/website.yaml +++ b/monitor-azure-resources-lj/navigate-azure-config/website.yaml @@ -10,6 +10,7 @@ keywords: - cloud provider - integration setup - navigation +description: Learn how to access the Azure integration configuration interface in Grafana Cloud. side_journeys: title: More to explore (optional) heading: 'At this point in your journey, you can explore the following paths:' diff --git a/monitor-azure-resources-lj/verify-data-collection/website.yaml b/monitor-azure-resources-lj/verify-data-collection/website.yaml index 9cec54f49..ee74e089c 100644 --- a/monitor-azure-resources-lj/verify-data-collection/website.yaml +++ b/monitor-azure-resources-lj/verify-data-collection/website.yaml @@ -10,3 +10,4 @@ keywords: - cloud provider - data verification - status check +description: Learn how to verify data is collected and being sent to Grafana Cloud. diff --git a/monitor-azure-resources-lj/website.yaml b/monitor-azure-resources-lj/website.yaml index 7617c27e1..ea25a2637 100644 --- a/monitor-azure-resources-lj/website.yaml +++ b/monitor-azure-resources-lj/website.yaml @@ -23,3 +23,4 @@ keywords: - observability - Grafana Cloud - serverless +description: Welcome to the Grafana learning path that shows you how to monitor Azure resources with Grafana Cloud Provider Observability.