From 1b74205f707d071d19755733daeecf8f2f894ee0 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 10:41:22 +0000 Subject: [PATCH 01/13] Add release-note agent, skill and generator; add v0.10 release note draft Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 49 ++ .github/skills/release-note/SKILL.md | 194 +++++++ .../releases/release_note_score_v0_10.rst | 254 +++++++++ docs/s_core_v_1/releases/releases.rst | 11 +- scripts/release_note/collect_changes.py | 316 ++++++++++++ scripts/release_note/generate_release_note.py | 482 ++++++++++++++++++ 6 files changed, 1304 insertions(+), 2 deletions(-) create mode 100644 .github/agents/release-note.agent.md create mode 100644 .github/skills/release-note/SKILL.md create mode 100644 docs/s_core_v_1/releases/release_note_score_v0_10.rst create mode 100644 scripts/release_note/collect_changes.py create mode 100644 scripts/release_note/generate_release_note.py diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md new file mode 100644 index 00000000000..e15c95479f5 --- /dev/null +++ b/.github/agents/release-note.agent.md @@ -0,0 +1,49 @@ +--- +description: "Creates the S-CORE platform release note docs/s_core_v_1/releases/release_note_score_vX_Y.rst. Use when: preparing a release note for an upcoming platform release, regenerating one from known_good.json, or reviewing the module tables of an existing note. Module tables, platform scope and images are generated from known_good.json and the repository; the 'Changes to the Platform' chapter is written from this repository's PRs and the modules' own release notes (platform-level new features and breaking changes only)." +name: Release Note +argument-hint: "release tag and origin tag, e.g. 'v0.10.0 from v0.9.0'" +tools: [read, edit, search, execute, todo, web] +user-invocable: true +--- + +You are the **Release Note** author. Your single job is to produce a platform +release note for `reference_integration` that follows the format defined in +the `release-note` skill. + +You **orchestrate**; the **"how" lives in `.github/skills/release-note/SKILL.md`**. +Read it first and follow it exactly. + +## Constraints + +- DO NOT type module names, versions or links into the tables by hand; they + come from `scripts/release_note/generate_release_note.py`. +- DO NOT invent changes. Every statement must trace back to a merged PR or + closed issue of this repository, the `--changes` table, or a module's own + GitHub release notes (all via `collect_changes.py`). +- List only platform-level new features and breaking changes (see the skill); + module internals stay in the module release notes, which the note links. +- DO NOT write a summary into the Overview chapter and DO NOT remove the + chapter icons from the headings; both are fixed by the generator. +- DO NOT overwrite an existing `release_note_score_*.rst` without the user + asking for it; for comparisons write to a different path. +- DO NOT commit or push. +- ALWAYS surface generator warnings (missing tags, unreleased pins) to the user. + +## Workflow + +1. Resolve `release` and `origin` tags from the argument. If the release tag + does not exist yet, ask for the planned release date. +2. Prepare: `git fetch origin main --tags`; for an untagged release use + `--known-good-ref origin/main` in both scripts. +3. Run the generator to create the skeleton and the `--changes` table. +4. Run `collect_changes.py` and classify into platform-level new features and + breaking changes as described in the skill (use the impact hints). +5. Replace both `.. AI-FILL:` markers (New Features, Breaking Changes). +6. Append the new file to the `toctree` in + `docs/s_core_v_1/releases/releases.rst` (explicit order, newest last). +7. Run the validation checks from the skill. +8. Report: output path, generator warnings, modules with unreleased pins, + and anything you could not verify. + +The skill section "Run from scratch" has the exact commands; the run needs no +earlier conversation or temporary files. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md new file mode 100644 index 00000000000..9390624219b --- /dev/null +++ b/.github/skills/release-note/SKILL.md @@ -0,0 +1,194 @@ +--- +name: release-note +description: "How-to reference for creating the S-CORE platform release note (docs/s_core_v_1/releases/release_note_score_vX_Y.rst). Defines the document format, the data sources (known_good.json, git log, module GitHub releases), the generator script for the deterministic parts and the rules for the AI-written narrative sections. Use when preparing, regenerating or reviewing a platform release note. The release-note agent orchestrates the run; this skill holds the 'how'." +argument-hint: "release tag, e.g. v0.10.0 (and optionally origin tag)" +--- + +# Platform Release Note — the "how" + +Format basis: the official template `wp__platform_sw_release_note` +(`process_description/process/folder_templates/platform/docs/release/release_note.rst`), +adapted as described below. The **[release-note agent](../../agents/release-note.agent.md)** +runs the procedure; this skill holds the rules. + +## Document format + +| Section | Source | Written by | +|---|---|---| +| Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args, tag date | script | +| Overview (fixed intro sentence only, no summary of the changes) | release and origin tag | script | +| Disclaimer | fixed text | script | +| Changes to the Platform: New Features / Breaking Changes (platform-level only) | `collect_changes.py` (this repo's PRs, modules' own release notes, impact hints), `--changes` table | AI | +| Reference Integration scope: Version, Source / tag, Release notes, Platform Verification Report (bullets) | release tag | script | +| Integrated Software Modules (table incl. `score_platform` first; extra column Module verification report) | `known_good.json` group `target_sw` + `score_platform` | script | +| Associated Infrastructure Modules (table) | `known_good.json` group `tooling` (without `score_platform`) | script | +| Images (subchapter after the two module tables; table: Name, Tarball, How-to, Description) | release assets, README/runner docs at the tag | script | +| Known Issues, Upgrade Instructions, Contact | fixed text, link to `known_good.json` at the tag | script | + +The scope section describes this repository (reference_integration) and holds +links only; no change narrative. Changes of this repository and of the images +go into **Changes to the Platform**. + +Every chapter heading carries an icon (emoji) so that readers recognise the +topic at a glance. The mapping is `ICONS` in the script. The script also makes +the heading underline longer, because emoji are double-width and docutils would +warn about a too short underline (warnings are errors in the docs build). Use +the same mechanism when adding a chapter; do not add icons to the `~~~` item +subsections or to the title. + +Module tables have the columns **Name** (link to the repo at the pinned +tag/commit), **Version**, **Release notes** (link to the GitHub release of that +tag). The Integrated Software Modules table has a fourth column **Module +verification report**: a `:doc:` link taken from +`docs/verification_report/modules/index.rst` (always the latest report in the +current docs tree), `---` where the module has none (e.g. the platform). Every +empty table cell is written as `---`. +Never write module versions or links by hand. + +Image table: the image catalogue (`IMAGES` in the script) lists name, +`images/`, description and how-to candidates. An image is listed only if +`images/` exists at the release ref. Tarball is a release asset whose name +contains the image directory name, else `---` (currently no image tarballs are +published). How-to is the first candidate that exists at the ref, else `---`. +When an image is added or renamed in `images/`, update `IMAGES`. + +## Generator + +```bash +# Skeleton (known_good.json is read from the release tag if it exists, else the working tree) +python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 \ + --date 2026-11-02 --output docs/s_core_v_1/releases/release_note_score_v0_10.rst + +# Module version changes origin -> release (input for the narrative) +python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --changes +``` + +Behaviour worth knowing: + +- Tags are resolved with `git ls-remote --tags` on every module repo. A + `version` pin maps to `v` or ``; a `hash` pin maps to a tag only if + one points exactly at that commit, otherwise the row shows ` (unreleased)`, + links the commit and has no release-notes link. +- Warnings (`no tag for version ...`) on stderr mean a broken link. Fix the + `known_good.json` pin or report it, never silence it. +- Modules with `"enabled": false` are skipped. +- Unknown `known_good.json` groups abort the run; extend `SECTIONS` then. +- Narrative sections are emitted as `.. AI-FILL: ` comments + (only New Features and Breaking Changes; Overview has no AI text). + +## Run from scratch (any release, also for not yet tagged ones) + +The procedure is identical for existing and new releases; only the inputs differ. + +1. **Inputs.** `release` and `origin` tag (for example `v0.10.0` / `v0.9.0`), + output file `docs/s_core_v_1/releases/release_note_score_v_.rst`. + Planned release date if the release tag does not exist yet. +2. **Prepare.** `git fetch origin main --tags`. The local `main` may be behind; + for an upcoming release read `known_good.json` from `origin/main` with + `--known-good-ref origin/main` (the working tree stays untouched). Export + `GITHUB_PAT` for the GitHub API calls of `collect_changes.py` (anonymous + access is rate limited); never print it. +3. **Skeleton.** Run the generator (see above). Check the stderr warnings. +4. **Evidence.** Run `collect_changes.py` with the same `--release`, `--origin` + and `--known-good-ref`, plus the `--changes` table. Runtime is a few minutes. +5. **Narrative.** Replace the two `AI-FILL` markers (New Features, Breaking + Changes) with the rules below. Nothing else in the file is written by hand. +6. **Index.** `docs/s_core_v_1/releases/releases.rst` lists the notes in an + explicit order (a glob would sort `v0_10` before `v0_5`); append the new file + at the end of that `toctree`. +7. **Validate** (checklist below) and report. + +An existing note is never overwritten unless the user asks for it; to compare, +write to another path (for example under `/tmp`). + +Re-running with the same inputs gives the same tables and chapter structure. +The narrative may be worded differently, but it has to name the same items if +the evidence did not change; when in doubt, keep the items and cite the same +PRs. + +## Writing the narrative (AI-FILL) + +"Changes to the Platform" has exactly two chapters: **New Features** and +**Breaking Changes**. It lists **platform-level** changes only. The release note +already links the release notes of every module, so module internals are not +repeated here. + +What is platform-level: + +1. Module set: newly integrated, removed or renamed modules and major version + jumps (from the `--changes` table of `generate_release_note.py`). +2. Changes of this repository (reference_integration): new or changed images and + platforms, Bazel/toolchain updates, build and integration mechanisms (for + example the resolved-dependency override), new demos, documentation or + process deliverables. +3. Cross-module changes: the same change in several modules (for example all of + them moving from `baselibs_rust` to `baselibs`). Identical PR titles in + several repos, or a module's release note, point to them. +4. Breaking changes of a module **with impact on integrators or other modules**, + for example a removed flag that `known_good.json` still sets. Breaking changes + without such impact stay in the module's own release note. + +Evidence: + +```bash +python3 scripts/release_note/collect_changes.py --release v0.10.0 --origin v0.9.0 --output /tmp/changes.md +``` + +The output has one section for the merged PRs and closed issues of this +repository and one section per changed module with the **breaking** and +**new-feature** parts of the module's own GitHub release notes. For every +breaking part the script lists "impact hints": places in this repository that +mention a flag, label or symbol named there. `--module ` restricts the run, +`--module-prs` adds the merged PRs of a module (slow, only for a deep dive), +`--include-infra` adds the infrastructure modules. + +Classification: + +- Impact hints are leads, not verdicts. A hint is only meaningful if the hit really + uses what the module removed or changed (for example a flag set in + `known_good.json` or `.bazelrc` that the release note says was removed). A hit + that merely shares a name (a different configuration with a similar name) is a + false alarm; ignore it. +- **Breaking**: removed or renamed module, target, flag or API that integrators or + other modules use, changed behaviour requiring action. An impact hint in this + repository is strong evidence. Without any hint, treat it as module-internal + and omit it. +- **New feature**: new module, platform, image, integration capability or + deliverable; or a module feature that is visible across the platform. +- Anything else is omitted. When unsure, omit it and name it in the final report. +- Labels like `breaking` or `feature` are rare in the repositories and not + reliable; decide from title, description and the module's release note. If + `release-note:*` labels or a `release-note` block exist in a PR, they take + precedence. + +Rules: + +- Only state facts found in the evidence. No invented features, dates or versions. +- One `~~~` subsection per item, written for integrators; link to the PR, issue or + the module's release note as RST links (`` `text `__ ``). +- Breaking changes state what integrators have to do. +- Remove **Breaking Changes** only if there is none; never leave an `AI-FILL` + marker behind (`grep -n AI-FILL ` must be empty). +- Report items you left out although they looked relevant, and modules without + GitHub releases in the range. + +Known limits of the evidence: + +- Only some modules (for example Baselibs) structure their GitHub release notes + with "Breaking changes" and "New features" sections. For the others the + collector prints "No breaking/new-feature sections found"; there is then no + module-level evidence, and you must say so in the report instead of guessing. +- Hash-pinned modules (` (unreleased)`) have releases only up to their + latest tag; changes after it are not covered by release notes. +- Impact hints search `known_good.json`, `.bazelrc`, `bazel_common`, `patches`, + `images`, `showcases` and the integration tests; they do not understand code. + +## Validation + +- `grep -n "AI-FILL" ` is empty. +- `releases.rst` lists the new note last. +- Every `~~~` item and every bullet names a PR, issue or release note link. +- Every table row links to an existing tag (no stderr warnings). +- Platform scope version equals the `score_platform` row source. +- Docs build: `bazel run --lockfile_mode=error //:docs_shim -- --github_user=eclipse-score --github_repo=reference_integration` + (heavy; optional locally, CI runs it). diff --git a/docs/s_core_v_1/releases/release_note_score_v0_10.rst b/docs/s_core_v_1/releases/release_note_score_v0_10.rst new file mode 100644 index 00000000000..bd3e07bf831 --- /dev/null +++ b/docs/s_core_v_1/releases/release_note_score_v0_10.rst @@ -0,0 +1,254 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +S-CORE Platform v0.10 release note +================================== + +.. document:: S-CORE v0.10 release note + :id: doc__score_v010_release_note + :status: draft + :safety: QM + :security: YES + :realizes: wp__platform_sw_release_note + :version: 1 + +| **Platform Name:** S-CORE +| **Release Tag:** v0.10.0 +| **Origin Release Tag:** v0.9.0 +| **Release Date:** 2026-11-02 + + +📋 Overview +^^^^^^^^^^^^ + +This document provides an overview of the changes, improvements, and bug fixes +included in the software platform release version v0.10.0 as compared to the +previous platform release (v0.9.0). + + +📜 Disclaimer +-------------- + +This release note does not "release for production", as it does not come with a +safety argumentation and a performed safety assessment. +The work products compiled in the safety package are created with care according +to a process satisfying standards, but the project, being a non-profit and open +source organization, can not take over any liability for its content. + + +🔄 Changes to the Platform +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +✨ New Features +---------------- + +SBOM Generation +~~~~~~~~~~~~~~~ + +Added generation of a Software Bill of Materials for the product dependencies +(SPDX and CycloneDX) and for the build tools (SPDX), see the "Software Bill of +Materials" section of the README (`PR #304 `__). + +Module Integration Tests +~~~~~~~~~~~~~~~~~~~~~~~~ + +Added module integration tests for Lifecycle, Logging and Persistency to the +reference integration, so that their requirement coverage appears in the +verification reports (`PR #385 `__, `PR #387 `__, +`PR #388 `__). + +DR-008 Integration Workflow +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Added the DR-008 integration workflow (`PR #398 `__), building on the +resolved-dependency override mechanism introduced in v0.9. + +Documentation and Verification Reports +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- The documentation of the Time module is part of the integrated documentation + (`PR #348 `__). +- The module verification reports of Logging and Lifecycle are linked from the + verification report overview (`PR #367 `__, `PR #373 `__). +- The Unit Tests and Coverage summaries are nested under the Platform + Verification Report (`PR #358 `__). + +💥 Breaking Changes +-------------------- + +Baselibs +~~~~~~~~ + +Baselibs is integrated at a commit after ``v0.2.14`` and contains breaking +changes of ``v0.2.13`` and ``v0.2.14`` (see the `Baselibs release notes +`__): + +- ``score/datetime_converter`` was removed (`PR #471 `__). Use + POSIX or C library time APIs instead. +- The unused ``score/os`` libraries ``cpuid``, ``file_stream`` and ``make_dev`` + were removed (`PR #535 `__). +- The deprecated ``//src`` Bazel aliases and the string and atomic shims in + ``//score/memory`` were removed (`PR #598 `__). Use the + replacement targets. +- ``score::flatbuffers::LoadBuffer`` now takes ``score::cpp::pmr::vector`` + instead of ``std::pmr::vector`` (`PR #603 `__). + +No usage of the removed APIs was found in the configuration of the reference +integration. Action required: adapt own code that uses them. + +🎯 Reference Integration scope +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **Version:** ``v0.10.0`` +- **Source / tag:** `Reference integration `__ +- **Release notes:** `Reference integration release notes `__ +- **Platform Verification Report:** :need:`doc__platform_verification_report_latest` + + +🧩 Integrated Software Modules +------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 15 25 35 + + * - Name + - Version + - Release notes + - Module verification report + * - `Platform `__ + - ``v0.7.2`` + - `Release notes `__ + - --- + * - `Baselibs `__ + - ``ee40fec (unreleased)`` + - --- + - :doc:`Verification report ` + * - `Communication `__ + - ``313f117 (unreleased)`` + - --- + - --- + * - `Persistency `__ + - ``961c0a0 (unreleased)`` + - --- + - :doc:`Verification report ` + * - `Kyron `__ + - ``90fb902 (unreleased)`` + - --- + - --- + * - `Lifecycle `__ + - ``85da216 (unreleased)`` + - --- + - :doc:`Verification report ` + * - `Logging `__ + - ``3d98966 (unreleased)`` + - --- + - :doc:`Verification report ` + * - `Time `__ + - ``3723ce6 (unreleased)`` + - --- + - --- + * - `Configuration Management `__ + - ``d0dab01 (unreleased)`` + - --- + - --- + +🔧 Associated Infrastructure Modules +------------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Name + - Version + - Release notes + * - `Crates `__ + - ``v0.0.11`` + - `Release notes `__ + * - `ITF (Integration Testing Framework) `__ + - ``v0.6.0`` + - `Release notes `__ + * - `Tools `__ + - ``4b48cec (unreleased)`` + - --- + * - `Tooling `__ + - ``v2.2.3`` + - `Release notes `__ + * - `Bazel Platforms `__ + - ``v1.1.0`` + - `Release notes `__ + * - `Test Scenarios `__ + - ``v0.4.1`` + - `Release notes `__ + * - `Docs-as-code `__ + - ``v8.4.0`` + - `Release notes `__ + * - `Process description `__ + - ``v2.1.2`` + - `Release notes `__ + * - `Bazel CPP Toolchain `__ + - ``v1.0.3`` + - `Release notes `__ + +💿 Images +---------- + +.. list-table:: + :header-rows: 1 + :widths: 30 15 15 40 + + * - Name + - Tarball + - How-to + - Description + * - Reference Linux x86_64 image + - --- + - `How-to `__ + - Linux x86_64 Docker image bundling the S-CORE showcases. + * - Reference QNX x86_64 image + - --- + - `How-to `__ + - QNX x86_64 image, run in QEMU. + * - Reference QNX aarch64 image + - --- + - `How-to `__ + - QNX 8.0 aarch64 image, run in QEMU or on Raspberry Pi 4/5. + * - Reference Red Hat AutoSD Linux image (Experimental) + - --- + - `How-to `__ + - Red Hat AutoSD x86_64 image. + * - Reference Elektrobit corbos Linux for Safety Applications image (Experimental) + - --- + - `How-to `__ + - Elektrobit corbos Linux for Safety Applications (aarch64) image. + +🐞 Known Issues/Vulnerabilities and Bug Fixes +---------------------------------------------- + +- See release notes of every module separately + +🚀 Upgrade Instructions +------------------------ + +- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from known_good.json. +- Versions can be found under: https://github.com/eclipse-score/reference_integration/blob/v0.10.0/known_good.json + + +📧 Contact Information +----------------------- + +For any questions or support, please contact the *Project leads* or raise an issue/discussion. +https://projects.eclipse.org/projects/automotive.score + diff --git a/docs/s_core_v_1/releases/releases.rst b/docs/s_core_v_1/releases/releases.rst index af111240b0a..9788156b17a 100644 --- a/docs/s_core_v_1/releases/releases.rst +++ b/docs/s_core_v_1/releases/releases.rst @@ -15,8 +15,15 @@ Releases ============ +.. Explicit order: a glob would sort v0_10 before v0_5. + .. toctree:: :titlesonly: - :glob: - * + release_note_score_v0_5_alpha + release_note_score_v0_5_beta + release_note_score_v0_6 + release_note_score_v0_7 + release_note_score_v0_8 + release_note_score_v0_9 + release_note_score_v0_10 diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py new file mode 100644 index 00000000000..344d2eeb4e1 --- /dev/null +++ b/scripts/release_note/collect_changes.py @@ -0,0 +1,316 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Collect the evidence for the platform-level "Changes to the Platform" chapter. + +Platform-level means: changes of this repository (reference_integration), and +module changes that matter for integrators. The latter come from the modules' own +release notes (their "breaking" and "new features" sections) together with hints +where this repository uses an affected flag, label or target. Detailed per-module +PR lists are only produced on request (``--module-prs``). + +Examples: + python3 scripts/release_note/collect_changes.py --release v0.9.0 --origin v0.8.0 --output /tmp/changes.md + python3 scripts/release_note/collect_changes.py --release v0.10.0 --origin v0.9.0 --module-prs --module score_logging +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import generate_release_note as grn + +API = "https://api.github.com" + +BOT_AUTHORS = ("[bot]", "dependabot", "renovate", "eclipse-score-bot") +NOISE_TITLE = re.compile(r"^(chore|build|ci)\(deps\)|\bbump\b|update dependency|^update .* to v?\d", re.I) +BREAKING_TITLE = re.compile(r"^\w+(\([^)]*\))?!:|\bbreaking\b", re.I) +FEATURE_TITLE = re.compile(r"^(feat|feature)(\([^)]*\))?:|^add(ed)?\b|^introduce|^implement|^support", re.I) +CLOSES = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.I) + + +def api(path: str, params: dict[str, Any] | None = None) -> Any: + url = f"{API}{path}" + (f"?{urllib.parse.urlencode(params)}" if params else "") + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + if token := os.environ.get("GITHUB_PAT"): + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + + +def paged(path: str, params: dict[str, Any] | None = None, key: str | None = None) -> list[Any]: + items: list[Any] = [] + for page in range(1, 30): + data = api(path, {**(params or {}), "per_page": 100, "page": page}) + batch = data[key] if key else data + items += batch + if len(batch) < 100: + break + return items + + +@dataclass +class Issue: + number: int + title: str + labels: list[str] + url: str + + +@dataclass +class PullRequest: + number: int + title: str + labels: list[str] + author: str + url: str + body: str + issues: list[Issue] = field(default_factory=list) + + @property + def is_noise(self) -> bool: + return any(b in self.author.lower() for b in BOT_AUTHORS) or bool(NOISE_TITLE.search(self.title)) + + @property + def kind(self) -> str: + labels = " ".join(self.labels).lower() + " " + " ".join(l for i in self.issues for l in i.labels).lower() + if "breaking" in labels or BREAKING_TITLE.search(self.title) or "BREAKING CHANGE" in self.body: + return "breaking" + if re.search(r"feature|enhancement|\bfeat\b", labels) or FEATURE_TITLE.search(self.title): + return "feature" + return "other" + + +def repo_slug(repo_url: str) -> str: + return repo_url.removeprefix("https://github.com/") + + +def ref_date(slug: str, ref: str) -> str: + return api(f"/repos/{slug}/commits/{ref}")["commit"]["committer"]["date"] + + +def merged_pulls(slug: str, start: str, end: str) -> list[dict]: + """Closed PRs merged in [start, end]; sorted by update time so paging stops once PRs predate the window.""" + found: list[dict] = [] + for page in range(1, 60): + batch = api(f"/repos/{slug}/pulls", {"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100, "page": page}) + found += [p for p in batch if p.get("merged_at") and start <= p["merged_at"] <= end] + if len(batch) < 100 or batch[-1]["updated_at"] < start: + break + return found + + +def load_issue(slug: str, number: int) -> Issue | None: + try: + data = api(f"/repos/{slug}/issues/{number}") + except urllib.error.HTTPError: + return None + return Issue(number, data["title"], [l["name"] for l in data["labels"]], data["html_url"]) + + +def collect_prs(slug: str, base: str, head: str) -> list[PullRequest]: + prs = [] + for data in sorted(merged_pulls(slug, ref_date(slug, base), ref_date(slug, head)), key=lambda p: p["merged_at"]): + pr = PullRequest( + data["number"], data["title"], [l["name"] for l in data["labels"]], data["user"]["login"], data["html_url"], + data.get("body") or "", + ) + for issue_number in dict.fromkeys(int(n) for n in CLOSES.findall(pr.body)): + if issue := load_issue(slug, issue_number): + pr.issues.append(issue) + prs.append(pr) + return prs + + +def collect_prs_by_commits(slug: str, base: str, head: str) -> list[PullRequest]: + """PRs of a small repository via the commit range (squash commits end in "(#N)").""" + numbers: dict[int, None] = {} + for commit in paged(f"/repos/{slug}/compare/{base}...{head}", key="commits"): + subject = commit["commit"]["message"].splitlines()[0] + if match := re.search(r"\(#(\d+)\)\s*$", subject): + numbers.setdefault(int(match.group(1))) + prs = [] + for number in numbers: + try: + data = api(f"/repos/{slug}/pulls/{number}") + except urllib.error.HTTPError: + continue + pr = PullRequest( + number, data["title"], [l["name"] for l in data["labels"]], data["user"]["login"], data["html_url"], + data.get("body") or "", + ) + for issue_number in dict.fromkeys(int(n) for n in CLOSES.findall(pr.body)): + if issue := load_issue(slug, issue_number): + pr.issues.append(issue) + prs.append(pr) + return prs + + +def closed_issues(slug: str, base: str, head: str, linked: set[int]) -> list[Issue]: + start, end = ref_date(slug, base), ref_date(slug, head) + issues = paged(f"/repos/{slug}/issues", {"state": "closed", "since": start}) + return [ + Issue(i["number"], i["title"], [l["name"] for l in i["labels"]], i["html_url"]) + for i in issues + if "pull_request" not in i and start <= i["closed_at"] <= end and i["number"] not in linked + ] + + +def _line(pr: PullRequest, with_body: bool) -> str: + labels = f" [{', '.join(pr.labels)}]" if pr.labels else "" + text = f"- #{pr.number} {pr.title}{labels} ({pr.url})" + for issue in pr.issues: + issue_labels = f" [{', '.join(issue.labels)}]" if issue.labels else "" + text += f"\n - closes #{issue.number} {issue.title}{issue_labels} ({issue.url})" + if with_body and pr.body.strip(): + excerpt = re.sub(r"\s+", " ", re.sub(r"", "", pr.body, flags=re.S)).strip()[:240] + if excerpt: + text += f"\n - body: {excerpt}" + return text + + +def render_module(name: str, slug: str, base: str, head: str, own_repo: bool = False) -> str: + """own_repo: this repository; every PR is listed (version bumps and tooling updates are platform-level here).""" + prs = collect_prs_by_commits(slug, base, head) if own_repo else collect_prs(slug, base, head) + relevant = prs if own_repo else [p for p in prs if not p.is_noise] + out = [f"## {name}: {base} -> {head} ({slug}, {len(prs)} merged PRs, {len(prs) - len(relevant)} bot/dependency PRs omitted)", ""] + for kind, title in (("breaking", "Breaking candidates"), ("feature", "Feature candidates"), ("other", "Other merged PRs")): + group = [p for p in relevant if p.kind == kind] + out += [f"### {title} ({len(group)})"] + [_line(p, with_body=kind != "other") for p in group] + [""] + linked = {i.number for p in prs for i in p.issues} + extra = closed_issues(slug, base, head, linked) + out += [f"### Closed issues in the release window, not linked to a PR ({len(extra)})"] + out += [f"- #{i.number} {i.title}" + (f" [{', '.join(i.labels)}]" if i.labels else "") + f" ({i.url})" for i in extra] + return "\n".join(out) + "\n" + + +SECTION_HEADING = re.compile(r"^(#{2,4})\s+(.*?)\s*$", re.M) +WANTED_SECTION = re.compile(r"breaking|deprecat|incompatible|new feature|features", re.I) +IMPACT_PATHS = ["known_good.json", ".bazelrc", "bazel_common", "patches", "images", "showcases", "MODULE.bazel", "feature_integration_tests", "platform_integration_tests"] + + +def release_sections(body: str) -> list[tuple[str, str]]: + """(heading, text) of the breaking / new-feature sections of a GitHub release body.""" + marks = list(SECTION_HEADING.finditer(body)) + sections = [] + for i, mark in enumerate(marks): + if WANTED_SECTION.search(mark.group(2)): + end = marks[i + 1].start() if i + 1 < len(marks) else len(body) + sections.append((mark.group(2), body[mark.end():end].strip())) + return sections + + +def impact_hints(text: str, module_key: str) -> list[str]: + """Lines in this repository that mention an identifier named in backticks and belong to the module.""" + short = module_key.removeprefix("score_") + hints = [] + seen: set[tuple[str, str]] = set() + for token in dict.fromkeys(re.findall(r"`([^`\s]{8,})`", text)): + if not re.search(r"[/:_.-]", token): + continue # plain words like `result` match everywhere + needles = {token, re.split(r"[:/]", token)[-1]} if re.search(r"[:/]", token) else {token} + for needle in (n for n in needles if len(n) >= 8): + result = subprocess.run( + ["git", "grep", "-n", "-F", "-I", needle, "--", *IMPACT_PATHS], + cwd=grn.REPO_ROOT, capture_output=True, text=True, + ) + for line in result.stdout.splitlines(): + path = line.split(":", 1)[0] + if short in line.lower() and (needle, path) not in seen: # one hint per file, line must concern this module + seen.add((needle, path)) + hints.append(f"`{needle}` is used in {line[:160]}") + return hints[:8] + + +def render_release_notes(name: str, slug: str, old: grn.Entry, new: grn.Entry) -> str: + start, end = ref_date(slug, old.ref), ref_date(slug, new.ref) + releases = [ + r for r in paged(f"/repos/{slug}/releases") + if r.get("published_at") and start < r["published_at"] <= end[:10] + "T23:59:59Z" and not r["draft"] + and r["tag_name"] != old.tag # the origin release is already part of the previous platform release + ] + out = [f"## {name}: {old.version_label} -> {new.version_label} ({slug}), module release notes", ""] + if not releases: + out += ["No GitHub releases in this range.", ""] + for release in sorted(releases, key=lambda r: r["published_at"]): + out += [f"### {release['tag_name']} ({release['published_at'][:10]}, {release['html_url']})", ""] + sections = release_sections(release.get("body") or "") + if not sections: + out += ["No breaking/new-feature sections found in the release text.", ""] + for heading, text in sections: + out += [f"#### {heading}", text, ""] + if re.search(r"breaking|deprecat|incompatible", heading, re.I): + out += [f"- impact hint: {hint}" for hint in impact_hints(text, new.key)] + [""] + return "\n".join(out) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--release", required=True) + parser.add_argument("--origin", required=True) + parser.add_argument("--module", action="append", help="Restrict module sections to these known_good keys (repeatable)") + parser.add_argument("--module-prs", action="store_true", help="Also list the merged PRs of each changed module (slow, large modules have hundreds)") + parser.add_argument("--include-infra", action="store_true", help="Also cover the Associated Infrastructure Modules") + parser.add_argument("--known-good-ref", help="Git ref to read known_good.json from (default: release tag if it exists, else the working tree), e.g. origin/main") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + ref = args.known_good_ref or (args.release if grn._tag_exists(args.release) else None) + current = {e.key: (g, e) for g, es in grn.collect(grn.load_known_good(ref)).items() for e in es} + previous = {e.key: e for es in grn.collect(grn.load_known_good(args.origin)).values() for e in es} + + sections = [] + ref_slug = f"eclipse-score/{grn.RELEASE_REPO}" + head = args.release if grn._tag_exists(args.release) else "main" + sections.append(render_module("Reference integration", ref_slug, args.origin, head, own_repo=True)) + + renamed_from = {new: old for old, new in grn.KEY_ALIASES.items()} + for key, (group, entry) in current.items(): + if args.module and key not in args.module: + continue + if group == "tooling" and not args.include_infra: + continue + old = previous.get(key) or previous.get(renamed_from.get(key, "")) + slug = repo_slug(entry.repo_url) + if old is None: + sections.append(f"## {entry.name}: newly integrated at {entry.version_label} ({slug})\n") + continue + if old.key != key: + sections.append(f"## {entry.name}: RENAMED from `{old.key}` to `{key}` (breaking for integrators)\n") + if old.pin != entry.pin: + sections.append(render_release_notes(entry.name, slug, old, entry)) + if args.module_prs: + sections.append(render_module(entry.name, slug, old.ref, entry.ref)) + for key, old in previous.items(): + if key not in current and not (key in grn.KEY_ALIASES and grn.KEY_ALIASES[key] in current) and (not args.module or key in args.module): + sections.append(f"## {old.name}: removed from the platform (was {old.version_label})\n") + + text = "\n".join(sections) + if args.output: + args.output.write_text(text) + else: + print(text) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py new file mode 100644 index 00000000000..ebf526133a5 --- /dev/null +++ b/scripts/release_note/generate_release_note.py @@ -0,0 +1,482 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Generate the deterministic skeleton of an S-CORE platform release note. + +Everything that can be derived from ``known_good.json`` (platform scope and the +module tables) is rendered here. Narrative sections are emitted as +``.. AI-FILL: ...`` comments that the release-note agent replaces. + +Examples: + # Skeleton for an already tagged release (known_good.json is read from the tag) + python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 + + # Skeleton for an upcoming release (known_good.json is read from the working tree) + python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --date 2026-11-02 + + # Module version changes between the two releases + python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 --changes +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +ORG_URL = "https://github.com/eclipse-score" +RELEASE_REPO = "reference_integration" + +# known_good.json group -> section title; score_platform is moved from "tooling" to "target_sw". +PLATFORM_MODULE = "score_platform" +SECTIONS = { + "target_sw": "Integrated Software Modules", + "tooling": "Associated Infrastructure Modules", +} + +# Module verification reports are linked from the current docs tree (same convention as the platform report). +VERIFICATION_INDEX = "docs/verification_report/modules/index.rst" +EMPTY_CELL = "---" +# Old module names in earlier known_good.json versions -> current name used in the docs tree. +KEY_ALIASES = {"score_lifecycle_health": "score_lifecycle", "score_process": "score_process_description"} + +# (name, images/, description, how-to candidates [(path, heading or None)] tried in order) +IMAGES = [ + ( + "Reference Linux x86_64 image", + "linux_x86_64", + "Linux x86_64 Docker image bundling the S-CORE showcases.", + [("README.md", "Run showcases"), ("README.md", "Operating system integrations")], + ), + ( + "Reference QNX x86_64 image", + "qnx_x86_64", + "QNX x86_64 image, run in QEMU.", + [("runners/qemu_x86_64/README.md", None)], + ), + ( + "Reference QNX aarch64 image", + "qnx_aarch64", + "QNX 8.0 aarch64 image, run in QEMU or on Raspberry Pi 4/5.", + [("README.md", "Build and deploy showcases on Raspberry Pi (QNX aarch64)")], + ), + ( + "Reference Red Hat AutoSD Linux image (Experimental)", + "autosd", + "Red Hat AutoSD x86_64 image.", + [("README.md", "Operating system integrations")], + ), + ( + "Reference Elektrobit corbos Linux for Safety Applications image (Experimental)", + "ebclfsa_aarch64", + "Elektrobit corbos Linux for Safety Applications (aarch64) image.", + [("images/ebclfsa_aarch64/README.md", None)], + ), +] + +DISPLAY_NAMES = { + "score_baselibs": "Baselibs", + "score_baselibs_rust": "Baselibs Rust", + "score_communication": "Communication", + "score_persistency": "Persistency", + "score_logging": "Logging", + "score_time": "Time", + "score_config_management": "Configuration Management", + "score_orchestrator": "Orchestrator", + "score_kyron": "Kyron", + "score_lifecycle": "Lifecycle", + "score_lifecycle_health": "Lifecycle & Health Management", + "score_process": "Process description", + "score_process_description": "Process description", + "score_docs_as_code": "Docs-as-code", + "score_tooling": "Tooling", + "score_tools": "Tools", + "score_platform": "Platform", + "score_itf": "ITF (Integration Testing Framework)", + "score_test_scenarios": "Test Scenarios", + "score_crates": "Crates", + "score_bazel_cpp_toolchains": "Bazel CPP Toolchain", + "score_bazel_platforms": "Bazel Platforms", +} + + +@dataclass +class Entry: + key: str + name: str + repo_url: str + tag: str | None # release tag the pin resolves to, None if unreleased + ref: str # tag or commit used for the source link + pin: str # version or hash as written in known_good.json + verification_doc: str | None = None # absolute Sphinx doc path of the module verification report + + @property + def source_url(self) -> str: + return f"{self.repo_url}/tree/{self.ref}" + + @property + def release_notes_url(self) -> str | None: + return f"{self.repo_url}/releases/tag/{self.tag}" if self.tag else None + + @property + def version_label(self) -> str: + return self.tag if self.tag else f"{self.pin[:7]} (unreleased)" + + +def _git(*args: str) -> str: + return subprocess.run(["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True).stdout + + +def _tag_exists(tag: str) -> bool: + cmd = ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"] + return subprocess.run(cmd, cwd=REPO_ROOT, stdout=subprocess.DEVNULL).returncode == 0 + + +def load_known_good(ref: str | None) -> dict: + text = _git("show", f"{ref}:known_good.json") if ref else (REPO_ROOT / "known_good.json").read_text() + return json.loads(text) + + +def remote_tags(repo: str, cache: dict[str, dict[str, str]]) -> dict[str, str]: + """Map tag name -> commit sha (peeled for annotated tags).""" + if repo not in cache: + out = subprocess.run( + ["git", "ls-remote", "--tags", repo], check=True, capture_output=True, text=True, timeout=60 + ).stdout + tags: dict[str, str] = {} + for line in out.splitlines(): + sha, ref = line.split("\t") + name = ref.removeprefix("refs/tags/") + if name.endswith("^{}"): + tags[name[:-3]] = sha + else: + tags.setdefault(name, sha) + cache[repo] = tags + return cache[repo] + + +def _version_key(tag: str) -> tuple[int, ...]: + return tuple(int(p) for p in re.findall(r"\d+", tag)) + + +def resolve(key: str, module: dict, cache: dict[str, dict[str, str]]) -> Entry: + repo = module["repo"] + repo_url = repo.removesuffix(".git") + tags = remote_tags(repo, cache) + name = DISPLAY_NAMES.get(key) or key.removeprefix("score_").replace("_", " ").title() + + version = module.get("version") + if version: + tag = next((c for c in (f"v{version}", version) if c in tags), None) + if tag is None: + print(f"warning: {key}: no tag for version {version} in {repo}", file=sys.stderr) + tag = f"v{version}" + return Entry(key, name, repo_url, tag, tag, version) + + commit = module["hash"] + matches = sorted((t for t, c in tags.items() if c == commit), key=_version_key) + tag = matches[-1] if matches else None + return Entry(key, name, repo_url, tag, tag or commit, commit) + + +def verification_docs() -> dict[str, str]: + """Map module key -> Sphinx doc path, parsed from the module verification report index.""" + text = (REPO_ROOT / VERIFICATION_INDEX).read_text() + return {m.group(2): m.group(1) for m in re.finditer(r":doc:`[^`<]*<(/modules/(\w+)/[^>\s]+)>`", text)} + + +def collect(known_good: dict) -> dict[str, list[Entry]]: + cache: dict[str, dict[str, str]] = {} + reports = verification_docs() + result: dict[str, list[Entry]] = {} + for group, modules in known_good["modules"].items(): + if group not in SECTIONS: + raise SystemExit(f"error: unknown known_good group '{group}', extend SECTIONS") + result[group] = [resolve(k, m, cache) for k, m in modules.items() if m.get("enabled", True)] + for entries in result.values(): + for e in entries: + e.verification_doc = reports.get(KEY_ALIASES.get(e.key, e.key)) + + platform = next((e for e in result.get("tooling", []) if e.key == PLATFORM_MODULE), None) + if platform: + result["tooling"].remove(platform) + result.setdefault("target_sw", []).insert(0, platform) + return result + + +def render_table(entries: list[Entry], with_verification: bool = False) -> str: + widths = "25 15 25 35" if with_verification else "30 25 45" + rows = [ + "", + ".. list-table::", + " :header-rows: 1", + f" :widths: {widths}", + "", + " * - Name", + " - Version", + " - Release notes", + ] + if with_verification: + rows.append(" - Module verification report") + for e in entries: + notes = f"`Release notes <{e.release_notes_url}>`__" if e.release_notes_url else EMPTY_CELL + rows += [ + f" * - `{e.name} <{e.source_url}>`__", + f" - ``{e.version_label}``", + f" - {notes}", + ] + if with_verification: + rows.append(f" - :doc:`Verification report <{e.verification_doc}>`" if e.verification_doc else f" - {EMPTY_CELL}") + return "\n".join(rows) + + +def _exists(ref: str | None, path: str) -> bool: + if ref is None: + return (REPO_ROOT / path).exists() + return subprocess.run(["git", "cat-file", "-e", f"{ref}:{path}"], cwd=REPO_ROOT, capture_output=True).returncode == 0 + + +def _anchors(ref: str | None, path: str) -> set[str]: + text = _git("show", f"{ref}:{path}") if ref else (REPO_ROOT / path).read_text() + anchors = set() + for line in text.splitlines(): + if line.startswith("#"): + slug = re.sub(r"[^\w\- ]", "", line.lstrip("# ").strip().lower()) + anchors.add(slug.replace(" ", "-")) + return anchors + + +def _howto(ref: str | None, release: str, candidates: list[tuple[str, str | None]]) -> str | None: + for path, heading in candidates: + if not _exists(ref, path): + continue + base = f"{ORG_URL}/{RELEASE_REPO}/blob/{release}/{path}" + if heading is None: + return base + slug = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-") + if slug in _anchors(ref, path): + return f"{base}#{slug}" + return None + + +def release_assets(release: str) -> list[dict]: + url = f"https://api.github.com/repos/eclipse-score/{RELEASE_REPO}/releases/tags/{release}" + request = urllib.request.Request(url) + if token := os.environ.get("GITHUB_PAT"): + request.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response).get("assets", []) + except OSError: + return [] # release not published yet + + +def render_images(release: str, ref: str | None) -> str: + assets = release_assets(release) + rows = [ + "", + ".. list-table::", + " :header-rows: 1", + " :widths: 30 15 15 40", + "", + " * - Name", + " - Tarball", + " - How-to", + " - Description", + ] + for name, directory, description, candidates in IMAGES: + if not _exists(ref, f"images/{directory}"): + continue + asset = next((a for a in assets if directory in a["name"]), None) + howto = _howto(ref, release, candidates) + rows += [ + f" * - {name}", + f" - `{asset['name']} <{asset['browser_download_url']}>`__" if asset else f" - {EMPTY_CELL}", + f" - `How-to <{howto}>`__" if howto else f" - {EMPTY_CELL}", + f" - {description}", + ] + return "\n".join(rows) + + +# Chapter icons; emoji are double-width, so the underline gets extra length. +ICONS = { + "Overview": "📋", + "Disclaimer": "📜", + "Changes to the Platform": "🔄", + "New Features": "✨", + "Breaking Changes": "💥", + "Reference Integration scope": "🎯", + "Integrated Software Modules": "🧩", + "Associated Infrastructure Modules": "🔧", + "Images": "💿", + "Known Issues/Vulnerabilities and Bug Fixes": "🐞", + "Upgrade Instructions": "🚀", + "Contact Information": "📧", +} + + +def _heading(text: str, char: str) -> str: + icon = ICONS.get(text) + if icon: + text = f"{icon} {text}" + return f"{text}\n{char * (len(text) + 2)}" + + +def render(release: str, origin: str, date: str, status: str, groups: dict[str, list[Entry]], ref: str | None) -> str: + short = ".".join(release.removeprefix("v").split(".")[:2]) + doc_id = "v" + short.replace(".", "") + title = f"S-CORE Platform v{short} release note" + release_url = f"{ORG_URL}/{RELEASE_REPO}" + + header = """.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* +""" + parts = [ + header, + _heading(title, "="), + f""" +.. document:: S-CORE v{short} release note + :id: doc__score_{doc_id}_release_note + :status: {status} + :safety: QM + :security: YES + :realizes: wp__platform_sw_release_note + :version: 1 + +| **Platform Name:** S-CORE +| **Release Tag:** {release} +| **Origin Release Tag:** {origin} +| **Release Date:** {date} + +""", + _heading("Overview", "^"), + f""" +This document provides an overview of the changes, improvements, and bug fixes +included in the software platform release version {release} as compared to the +previous platform release ({origin}). + +""", + _heading("Disclaimer", "-"), + """ +This release note does not "release for production", as it does not come with a +safety argumentation and a performed safety assessment. +The work products compiled in the safety package are created with care according +to a process satisfying standards, but the project, being a non-profit and open +source organization, can not take over any liability for its content. + +""", + _heading("Changes to the Platform", "^"), + "\n" + _heading("New Features", "-"), + "\n.. AI-FILL: New Features - platform-level only (see skill): one subsection (~~~) per item, each with links to PR, issue or module release note.\n", + _heading("Breaking Changes", "-"), + "\n.. AI-FILL: Breaking Changes - only changes with impact on integrators (see skill), with migration hint; remove the chapter if none.\n", + _heading("Reference Integration scope", "^"), + f""" +- **Version:** ``{release}`` +- **Source / tag:** `Reference integration <{release_url}/tree/{release}>`__ +- **Release notes:** `Reference integration release notes <{release_url}/releases/tag/{release}>`__ +- **Platform Verification Report:** :need:`doc__platform_verification_report_latest` + +""", + ] + + for group, title_text in SECTIONS.items(): + parts += [_heading(title_text, "-"), render_table(groups[group], with_verification=group == "target_sw") + "\n"] + parts += [_heading("Images", "-"), render_images(release, ref) + "\n"] + + unreleased = any(e.tag is None for entries in groups.values() for e in entries) + registry_hint = ( + "- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from known_good.json." + if unreleased + else "- Increase to newest bazel registry versions: https://eclipse-score.github.io/bazel_registry_ui" + ) + + parts += [ + _heading("Known Issues/Vulnerabilities and Bug Fixes", "-"), + "\n- See release notes of every module separately\n", + _heading("Upgrade Instructions", "-"), + f""" +{registry_hint} +- Versions can be found under: {release_url}/blob/{release}/known_good.json + +""", + _heading("Contact Information", "-"), + """ +For any questions or support, please contact the *Project leads* or raise an issue/discussion. +https://projects.eclipse.org/projects/automotive.score +""", + ] + return "\n".join(parts) + + +def render_changes(origin: dict[str, list[Entry]], current: dict[str, list[Entry]]) -> str: + old = {KEY_ALIASES.get(e.key, e.key): e for entries in origin.values() for e in entries} + new = {e.key: e for entries in current.values() for e in entries} + lines = ["| Module | Previous | Current |", "|---|---|---|"] + for key in sorted(old.keys() | new.keys()): + o, n = old.get(key), new.get(key) + renamed = f" **renamed from `{o.key}`**" if o and n and o.key != n.key else "" + if o and n and o.pin == n.pin and not renamed: + continue + lines.append( + f"| {(n or o).name} (`{key}`){renamed} | {o.version_label if o else 'new'} | {n.version_label if n else 'removed'} |" + ) + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--release", required=True, help="Release tag, e.g. v0.10.0") + parser.add_argument("--origin", required=True, help="Previous release tag, e.g. v0.9.0") + parser.add_argument("--known-good-ref", help="Git ref to read known_good.json from (default: release tag if it exists, else working tree)") + parser.add_argument("--date", help="Release date YYYY-MM-DD (default: tag date if the tag exists, else TBD)") + parser.add_argument("--status", default="draft", choices=["draft", "valid"]) + parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") + parser.add_argument("--output", type=Path, help="Write to file instead of stdout") + args = parser.parse_args() + + ref = args.known_good_ref or (args.release if _tag_exists(args.release) else None) + groups = collect(load_known_good(ref)) + + if args.changes: + text = render_changes(collect(load_known_good(args.origin)), groups) + else: + date = args.date or (_git("log", "-1", "--format=%cs", f"{args.release}^{{commit}}").strip() if _tag_exists(args.release) else "TBD") + text = render(args.release, args.origin, date, args.status, groups, ref) + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text + "\n") + else: + print(text) + + +if __name__ == "__main__": + main() From 5f24854c748671e048c14f1c605cacfe4fa8f366 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:05:10 +0000 Subject: [PATCH 02/13] Format release-note scripts with ruff Signed-off-by: Anton Krivoborodov --- scripts/release_note/collect_changes.py | 82 +++++++++++++++---- scripts/release_note/generate_release_note.py | 21 ++++- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index 344d2eeb4e1..3f456f479a0 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -113,7 +113,10 @@ def merged_pulls(slug: str, start: str, end: str) -> list[dict]: """Closed PRs merged in [start, end]; sorted by update time so paging stops once PRs predate the window.""" found: list[dict] = [] for page in range(1, 60): - batch = api(f"/repos/{slug}/pulls", {"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100, "page": page}) + batch = api( + f"/repos/{slug}/pulls", + {"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100, "page": page}, + ) found += [p for p in batch if p.get("merged_at") and start <= p["merged_at"] <= end] if len(batch) < 100 or batch[-1]["updated_at"] < start: break @@ -132,7 +135,11 @@ def collect_prs(slug: str, base: str, head: str) -> list[PullRequest]: prs = [] for data in sorted(merged_pulls(slug, ref_date(slug, base), ref_date(slug, head)), key=lambda p: p["merged_at"]): pr = PullRequest( - data["number"], data["title"], [l["name"] for l in data["labels"]], data["user"]["login"], data["html_url"], + data["number"], + data["title"], + [l["name"] for l in data["labels"]], + data["user"]["login"], + data["html_url"], data.get("body") or "", ) for issue_number in dict.fromkeys(int(n) for n in CLOSES.findall(pr.body)): @@ -156,7 +163,11 @@ def collect_prs_by_commits(slug: str, base: str, head: str) -> list[PullRequest] except urllib.error.HTTPError: continue pr = PullRequest( - number, data["title"], [l["name"] for l in data["labels"]], data["user"]["login"], data["html_url"], + number, + data["title"], + [l["name"] for l in data["labels"]], + data["user"]["login"], + data["html_url"], data.get("body") or "", ) for issue_number in dict.fromkeys(int(n) for n in CLOSES.findall(pr.body)): @@ -193,20 +204,39 @@ def render_module(name: str, slug: str, base: str, head: str, own_repo: bool = F """own_repo: this repository; every PR is listed (version bumps and tooling updates are platform-level here).""" prs = collect_prs_by_commits(slug, base, head) if own_repo else collect_prs(slug, base, head) relevant = prs if own_repo else [p for p in prs if not p.is_noise] - out = [f"## {name}: {base} -> {head} ({slug}, {len(prs)} merged PRs, {len(prs) - len(relevant)} bot/dependency PRs omitted)", ""] - for kind, title in (("breaking", "Breaking candidates"), ("feature", "Feature candidates"), ("other", "Other merged PRs")): + out = [ + f"## {name}: {base} -> {head} ({slug}, {len(prs)} merged PRs, {len(prs) - len(relevant)} bot/dependency PRs omitted)", + "", + ] + for kind, title in ( + ("breaking", "Breaking candidates"), + ("feature", "Feature candidates"), + ("other", "Other merged PRs"), + ): group = [p for p in relevant if p.kind == kind] out += [f"### {title} ({len(group)})"] + [_line(p, with_body=kind != "other") for p in group] + [""] linked = {i.number for p in prs for i in p.issues} extra = closed_issues(slug, base, head, linked) out += [f"### Closed issues in the release window, not linked to a PR ({len(extra)})"] - out += [f"- #{i.number} {i.title}" + (f" [{', '.join(i.labels)}]" if i.labels else "") + f" ({i.url})" for i in extra] + out += [ + f"- #{i.number} {i.title}" + (f" [{', '.join(i.labels)}]" if i.labels else "") + f" ({i.url})" for i in extra + ] return "\n".join(out) + "\n" SECTION_HEADING = re.compile(r"^(#{2,4})\s+(.*?)\s*$", re.M) WANTED_SECTION = re.compile(r"breaking|deprecat|incompatible|new feature|features", re.I) -IMPACT_PATHS = ["known_good.json", ".bazelrc", "bazel_common", "patches", "images", "showcases", "MODULE.bazel", "feature_integration_tests", "platform_integration_tests"] +IMPACT_PATHS = [ + "known_good.json", + ".bazelrc", + "bazel_common", + "patches", + "images", + "showcases", + "MODULE.bazel", + "feature_integration_tests", + "platform_integration_tests", +] def release_sections(body: str) -> list[tuple[str, str]]: @@ -216,7 +246,7 @@ def release_sections(body: str) -> list[tuple[str, str]]: for i, mark in enumerate(marks): if WANTED_SECTION.search(mark.group(2)): end = marks[i + 1].start() if i + 1 < len(marks) else len(body) - sections.append((mark.group(2), body[mark.end():end].strip())) + sections.append((mark.group(2), body[mark.end() : end].strip())) return sections @@ -232,11 +262,15 @@ def impact_hints(text: str, module_key: str) -> list[str]: for needle in (n for n in needles if len(n) >= 8): result = subprocess.run( ["git", "grep", "-n", "-F", "-I", needle, "--", *IMPACT_PATHS], - cwd=grn.REPO_ROOT, capture_output=True, text=True, + cwd=grn.REPO_ROOT, + capture_output=True, + text=True, ) for line in result.stdout.splitlines(): path = line.split(":", 1)[0] - if short in line.lower() and (needle, path) not in seen: # one hint per file, line must concern this module + if ( + short in line.lower() and (needle, path) not in seen + ): # one hint per file, line must concern this module seen.add((needle, path)) hints.append(f"`{needle}` is used in {line[:160]}") return hints[:8] @@ -245,8 +279,11 @@ def impact_hints(text: str, module_key: str) -> list[str]: def render_release_notes(name: str, slug: str, old: grn.Entry, new: grn.Entry) -> str: start, end = ref_date(slug, old.ref), ref_date(slug, new.ref) releases = [ - r for r in paged(f"/repos/{slug}/releases") - if r.get("published_at") and start < r["published_at"] <= end[:10] + "T23:59:59Z" and not r["draft"] + r + for r in paged(f"/repos/{slug}/releases") + if r.get("published_at") + and start < r["published_at"] <= end[:10] + "T23:59:59Z" + and not r["draft"] and r["tag_name"] != old.tag # the origin release is already part of the previous platform release ] out = [f"## {name}: {old.version_label} -> {new.version_label} ({slug}), module release notes", ""] @@ -268,10 +305,19 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--release", required=True) parser.add_argument("--origin", required=True) - parser.add_argument("--module", action="append", help="Restrict module sections to these known_good keys (repeatable)") - parser.add_argument("--module-prs", action="store_true", help="Also list the merged PRs of each changed module (slow, large modules have hundreds)") + parser.add_argument( + "--module", action="append", help="Restrict module sections to these known_good keys (repeatable)" + ) + parser.add_argument( + "--module-prs", + action="store_true", + help="Also list the merged PRs of each changed module (slow, large modules have hundreds)", + ) parser.add_argument("--include-infra", action="store_true", help="Also cover the Associated Infrastructure Modules") - parser.add_argument("--known-good-ref", help="Git ref to read known_good.json from (default: release tag if it exists, else the working tree), e.g. origin/main") + parser.add_argument( + "--known-good-ref", + help="Git ref to read known_good.json from (default: release tag if it exists, else the working tree), e.g. origin/main", + ) parser.add_argument("--output", type=Path) args = parser.parse_args() @@ -302,7 +348,11 @@ def main() -> None: if args.module_prs: sections.append(render_module(entry.name, slug, old.ref, entry.ref)) for key, old in previous.items(): - if key not in current and not (key in grn.KEY_ALIASES and grn.KEY_ALIASES[key] in current) and (not args.module or key in args.module): + if ( + key not in current + and not (key in grn.KEY_ALIASES and grn.KEY_ALIASES[key] in current) + and (not args.module or key in args.module) + ): sections.append(f"## {old.name}: removed from the platform (was {old.version_label})\n") text = "\n".join(sections) diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index ebf526133a5..2ae6feff951 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -242,14 +242,20 @@ def render_table(entries: list[Entry], with_verification: bool = False) -> str: f" - {notes}", ] if with_verification: - rows.append(f" - :doc:`Verification report <{e.verification_doc}>`" if e.verification_doc else f" - {EMPTY_CELL}") + rows.append( + f" - :doc:`Verification report <{e.verification_doc}>`" + if e.verification_doc + else f" - {EMPTY_CELL}" + ) return "\n".join(rows) def _exists(ref: str | None, path: str) -> bool: if ref is None: return (REPO_ROOT / path).exists() - return subprocess.run(["git", "cat-file", "-e", f"{ref}:{path}"], cwd=REPO_ROOT, capture_output=True).returncode == 0 + return ( + subprocess.run(["git", "cat-file", "-e", f"{ref}:{path}"], cwd=REPO_ROOT, capture_output=True).returncode == 0 + ) def _anchors(ref: str | None, path: str) -> set[str]: @@ -455,7 +461,10 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--release", required=True, help="Release tag, e.g. v0.10.0") parser.add_argument("--origin", required=True, help="Previous release tag, e.g. v0.9.0") - parser.add_argument("--known-good-ref", help="Git ref to read known_good.json from (default: release tag if it exists, else working tree)") + parser.add_argument( + "--known-good-ref", + help="Git ref to read known_good.json from (default: release tag if it exists, else working tree)", + ) parser.add_argument("--date", help="Release date YYYY-MM-DD (default: tag date if the tag exists, else TBD)") parser.add_argument("--status", default="draft", choices=["draft", "valid"]) parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") @@ -468,7 +477,11 @@ def main() -> None: if args.changes: text = render_changes(collect(load_known_good(args.origin)), groups) else: - date = args.date or (_git("log", "-1", "--format=%cs", f"{args.release}^{{commit}}").strip() if _tag_exists(args.release) else "TBD") + date = args.date or ( + _git("log", "-1", "--format=%cs", f"{args.release}^{{commit}}").strip() + if _tag_exists(args.release) + else "TBD" + ) text = render(args.release, args.origin, date, args.status, groups, ref) if args.output: From c1e47d565a26229f2ab122500776e375e6e56986 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:08:53 +0000 Subject: [PATCH 03/13] Remove session-specific details from release-note skill and agent Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 7 +- .github/skills/release-note/SKILL.md | 70 ++++++++----------- scripts/release_note/collect_changes.py | 4 +- scripts/release_note/generate_release_note.py | 2 +- 4 files changed, 37 insertions(+), 46 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index e15c95479f5..cd2c1055c61 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -22,8 +22,8 @@ Read it first and follow it exactly. GitHub release notes (all via `collect_changes.py`). - List only platform-level new features and breaking changes (see the skill); module internals stay in the module release notes, which the note links. -- DO NOT write a summary into the Overview chapter and DO NOT remove the - chapter icons from the headings; both are fixed by the generator. +- DO NOT edit generated parts (Overview, headings and icons, tables); only + replace the `AI-FILL` markers. - DO NOT overwrite an existing `release_note_score_*.rst` without the user asking for it; for comparisons write to a different path. - DO NOT commit or push. @@ -45,5 +45,4 @@ Read it first and follow it exactly. 8. Report: output path, generator warnings, modules with unreleased pins, and anything you could not verify. -The skill section "Run from scratch" has the exact commands; the run needs no -earlier conversation or temporary files. +The skill section "Run from scratch" has the exact commands. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index 9390624219b..aa3c4438d1f 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -16,7 +16,7 @@ runs the procedure; this skill holds the rules. | Section | Source | Written by | |---|---|---| | Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args, tag date | script | -| Overview (fixed intro sentence only, no summary of the changes) | release and origin tag | script | +| Overview (fixed intro sentence) | release and origin tag | script | | Disclaimer | fixed text | script | | Changes to the Platform: New Features / Breaking Changes (platform-level only) | `collect_changes.py` (this repo's PRs, modules' own release notes, impact hints), `--changes` table | AI | | Reference Integration scope: Version, Source / tag, Release notes, Platform Verification Report (bullets) | release tag | script | @@ -29,12 +29,10 @@ The scope section describes this repository (reference_integration) and holds links only; no change narrative. Changes of this repository and of the images go into **Changes to the Platform**. -Every chapter heading carries an icon (emoji) so that readers recognise the -topic at a glance. The mapping is `ICONS` in the script. The script also makes -the heading underline longer, because emoji are double-width and docutils would -warn about a too short underline (warnings are errors in the docs build). Use -the same mechanism when adding a chapter; do not add icons to the `~~~` item -subsections or to the title. +Every chapter heading carries an icon (emoji); the mapping is `ICONS` in the +script. When adding a chapter, add its icon there instead of typing it into the +heading (the script also lengthens the underline for the double-width emoji). +The title and the `~~~` item subsections have no icon. Module tables have the columns **Name** (link to the repo at the pinned tag/commit), **Version**, **Release notes** (link to the GitHub release of that @@ -48,19 +46,18 @@ Never write module versions or links by hand. Image table: the image catalogue (`IMAGES` in the script) lists name, `images/`, description and how-to candidates. An image is listed only if `images/` exists at the release ref. Tarball is a release asset whose name -contains the image directory name, else `---` (currently no image tarballs are -published). How-to is the first candidate that exists at the ref, else `---`. +contains the image directory name, else `---`. How-to is the first candidate that exists at the ref, else `---`. When an image is added or renamed in `images/`, update `IMAGES`. ## Generator ```bash # Skeleton (known_good.json is read from the release tag if it exists, else the working tree) -python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 \ - --date 2026-11-02 --output docs/s_core_v_1/releases/release_note_score_v0_10.rst +python3 scripts/release_note/generate_release_note.py --release vX.Y.0 --origin vX.W.0 \ + --date YYYY-MM-DD --output docs/s_core_v_1/releases/release_note_score_vX_Y.rst # Module version changes origin -> release (input for the narrative) -python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --changes +python3 scripts/release_note/generate_release_note.py --release vX.Y.0 --origin vX.W.0 --changes ``` Behaviour worth knowing: @@ -73,38 +70,36 @@ Behaviour worth knowing: `known_good.json` pin or report it, never silence it. - Modules with `"enabled": false` are skipped. - Unknown `known_good.json` groups abort the run; extend `SECTIONS` then. -- Narrative sections are emitted as `.. AI-FILL: ` comments - (only New Features and Breaking Changes; Overview has no AI text). +- The two narrative sections (New Features, Breaking Changes) are emitted as + `.. AI-FILL: ` comments. ## Run from scratch (any release, also for not yet tagged ones) The procedure is identical for existing and new releases; only the inputs differ. -1. **Inputs.** `release` and `origin` tag (for example `v0.10.0` / `v0.9.0`), - output file `docs/s_core_v_1/releases/release_note_score_v_.rst`. - Planned release date if the release tag does not exist yet. -2. **Prepare.** `git fetch origin main --tags`. The local `main` may be behind; - for an upcoming release read `known_good.json` from `origin/main` with - `--known-good-ref origin/main` (the working tree stays untouched). Export - `GITHUB_PAT` for the GitHub API calls of `collect_changes.py` (anonymous - access is rate limited); never print it. +1. **Inputs.** `release` and `origin` tag, output file + `docs/s_core_v_1/releases/release_note_score_v_.rst`, and the + planned release date if the release tag does not exist yet. +2. **Prepare.** `git fetch origin main --tags`. For an upcoming release read + `known_good.json` from `origin/main` with `--known-good-ref origin/main` + (the working tree stays untouched). Set `GITHUB_TOKEN` for the GitHub API + calls (anonymous access is rate limited); never print it. 3. **Skeleton.** Run the generator (see above). Check the stderr warnings. 4. **Evidence.** Run `collect_changes.py` with the same `--release`, `--origin` and `--known-good-ref`, plus the `--changes` table. Runtime is a few minutes. 5. **Narrative.** Replace the two `AI-FILL` markers (New Features, Breaking Changes) with the rules below. Nothing else in the file is written by hand. 6. **Index.** `docs/s_core_v_1/releases/releases.rst` lists the notes in an - explicit order (a glob would sort `v0_10` before `v0_5`); append the new file - at the end of that `toctree`. + explicit order (oldest first); append the new file at the end of that + `toctree`. 7. **Validate** (checklist below) and report. An existing note is never overwritten unless the user asks for it; to compare, -write to another path (for example under `/tmp`). +write to another path. Re-running with the same inputs gives the same tables and chapter structure. -The narrative may be worded differently, but it has to name the same items if -the evidence did not change; when in doubt, keep the items and cite the same -PRs. +The narrative may be worded differently, but it has to name the same items as +long as the evidence did not change. ## Writing the narrative (AI-FILL) @@ -118,12 +113,11 @@ What is platform-level: 1. Module set: newly integrated, removed or renamed modules and major version jumps (from the `--changes` table of `generate_release_note.py`). 2. Changes of this repository (reference_integration): new or changed images and - platforms, Bazel/toolchain updates, build and integration mechanisms (for - example the resolved-dependency override), new demos, documentation or - process deliverables. -3. Cross-module changes: the same change in several modules (for example all of - them moving from `baselibs_rust` to `baselibs`). Identical PR titles in - several repos, or a module's release note, point to them. + platforms, Bazel/toolchain updates, build and integration mechanisms, new + demos, documentation or process deliverables. +3. Cross-module changes: the same change in several modules (for example a + dependency that all modules replace). Identical PR titles in several repos, + or a module's release note, point to them. 4. Breaking changes of a module **with impact on integrators or other modules**, for example a removed flag that `known_good.json` still sets. Breaking changes without such impact stay in the module's own release note. @@ -131,7 +125,7 @@ What is platform-level: Evidence: ```bash -python3 scripts/release_note/collect_changes.py --release v0.10.0 --origin v0.9.0 --output /tmp/changes.md +python3 scripts/release_note/collect_changes.py --release vX.Y.0 --origin vX.W.0 --output changes.md ``` The output has one section for the merged PRs and closed issues of this @@ -156,10 +150,8 @@ Classification: - **New feature**: new module, platform, image, integration capability or deliverable; or a module feature that is visible across the platform. - Anything else is omitted. When unsure, omit it and name it in the final report. -- Labels like `breaking` or `feature` are rare in the repositories and not - reliable; decide from title, description and the module's release note. If - `release-note:*` labels or a `release-note` block exist in a PR, they take - precedence. +- PR labels like `breaking` or `feature` are not used consistently; decide from + title, description and the module's release note. Rules: diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index 3f456f479a0..b8269c056d6 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -19,7 +19,7 @@ PR lists are only produced on request (``--module-prs``). Examples: - python3 scripts/release_note/collect_changes.py --release v0.9.0 --origin v0.8.0 --output /tmp/changes.md + python3 scripts/release_note/collect_changes.py --release v0.9.0 --origin v0.8.0 --output changes.md python3 scripts/release_note/collect_changes.py --release v0.10.0 --origin v0.9.0 --module-prs --module score_logging """ @@ -52,7 +52,7 @@ def api(path: str, params: dict[str, Any] | None = None) -> Any: url = f"{API}{path}" + (f"?{urllib.parse.urlencode(params)}" if params else "") request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) - if token := os.environ.get("GITHUB_PAT"): + if token := os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PAT"): request.add_header("Authorization", f"Bearer {token}") with urllib.request.urlopen(request, timeout=60) as response: return json.load(response) diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 2ae6feff951..72e845bd1f3 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -284,7 +284,7 @@ def _howto(ref: str | None, release: str, candidates: list[tuple[str, str | None def release_assets(release: str) -> list[dict]: url = f"https://api.github.com/repos/eclipse-score/{RELEASE_REPO}/releases/tags/{release}" request = urllib.request.Request(url) - if token := os.environ.get("GITHUB_PAT"): + if token := os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PAT"): request.add_header("Authorization", f"Bearer {token}") try: with urllib.request.urlopen(request, timeout=30) as response: From 0fd59d326fb8fdbc5e696e6ba2db3342077ed1b3 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:18:16 +0000 Subject: [PATCH 04/13] Release note: list removed files as evidence, correct v0.10 content Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 3 +- .github/skills/release-note/SKILL.md | 48 +++++++---- .../releases/release_note_score_v0_10.rst | 84 ++++++++++++++----- scripts/release_note/collect_changes.py | 27 +++++- scripts/release_note/generate_release_note.py | 13 +-- 5 files changed, 130 insertions(+), 45 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index cd2c1055c61..cca5ce62e70 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -43,6 +43,7 @@ Read it first and follow it exactly. `docs/s_core_v_1/releases/releases.rst` (explicit order, newest last). 7. Run the validation checks from the skill. 8. Report: output path, generator warnings, modules with unreleased pins, - and anything you could not verify. + items left out although they looked relevant, and anything you could not + verify. The skill section "Run from scratch" has the exact commands. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index aa3c4438d1f..67855f3455e 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -1,6 +1,6 @@ --- name: release-note -description: "How-to reference for creating the S-CORE platform release note (docs/s_core_v_1/releases/release_note_score_vX_Y.rst). Defines the document format, the data sources (known_good.json, git log, module GitHub releases), the generator script for the deterministic parts and the rules for the AI-written narrative sections. Use when preparing, regenerating or reviewing a platform release note. The release-note agent orchestrates the run; this skill holds the 'how'." +description: "How-to reference for creating the S-CORE platform release note (docs/s_core_v_1/releases/release_note_score_vX_Y.rst). Defines the document format, the data sources (known_good.json, merged PRs and removed files of this repository, module GitHub releases), the generator script for the deterministic parts and the rules for the AI-written narrative sections. Use when preparing, regenerating or reviewing a platform release note. The release-note agent orchestrates the run; this skill holds the 'how'." argument-hint: "release tag, e.g. v0.10.0 (and optionally origin tag)" --- @@ -23,7 +23,7 @@ runs the procedure; this skill holds the rules. | Integrated Software Modules (table incl. `score_platform` first; extra column Module verification report) | `known_good.json` group `target_sw` + `score_platform` | script | | Associated Infrastructure Modules (table) | `known_good.json` group `tooling` (without `score_platform`) | script | | Images (subchapter after the two module tables; table: Name, Tarball, How-to, Description) | release assets, README/runner docs at the tag | script | -| Known Issues, Upgrade Instructions, Contact | fixed text, link to `known_good.json` at the tag | script | +| Known Issues, Upgrade Instructions (registry hint, hint for commit pins, link to `known_good.json` at the tag), Contact | fixed text | script | The scope section describes this repository (reference_integration) and holds links only; no change narrative. Changes of this repository and of the images @@ -95,7 +95,10 @@ The procedure is identical for existing and new releases; only the inputs differ 7. **Validate** (checklist below) and report. An existing note is never overwritten unless the user asks for it; to compare, -write to another path. +write to another path. To refresh the tables of an existing draft (for example +after pin bumps, or with `--status valid` once the tag exists), generate to +another path and replace everything outside "Changes to the Platform"; keep the +reviewed narrative. Re-running with the same inputs gives the same tables and chapter structure. The narrative may be worded differently, but it has to name the same items as @@ -129,8 +132,10 @@ python3 scripts/release_note/collect_changes.py --release vX.Y.0 --origin vX.W.0 ``` The output has one section for the merged PRs and closed issues of this -repository and one section per changed module with the **breaking** and -**new-feature** parts of the module's own GitHub release notes. For every +repository, one for the files of this repository that were removed or renamed +(per directory, without `docs/` and `patches/`), and one section per changed +module with the **breaking** and **new-feature** parts of the module's own +GitHub release notes. For every breaking part the script lists "impact hints": places in this repository that mention a flag, label or symbol named there. `--module ` restricts the run, `--module-prs` adds the merged PRs of a module (slow, only for a deep dive), @@ -143,10 +148,16 @@ Classification: `known_good.json` or `.bazelrc` that the release note says was removed). A hit that merely shares a name (a different configuration with a similar name) is a false alarm; ignore it. -- **Breaking**: removed or renamed module, target, flag or API that integrators or - other modules use, changed behaviour requiring action. An impact hint in this - repository is strong evidence. Without any hint, treat it as module-internal - and omit it. +- **Breaking**: + - this repository: a removed or renamed script, target, configuration file or + mechanism, in particular one that an earlier release note announced. The + "removed or renamed files" section points to them; read the PR that removed + them. + - a module: a removed or renamed module, target, flag or public API, or + changed behaviour requiring action, as stated in the module's release note + ("breaking" section). List it if this repository uses it (impact hint) or + if it is a public API that applications use directly; say which of the two + applies. Changes of internals stay in the module's release note. - **New feature**: new module, platform, image, integration capability or deliverable; or a module feature that is visible across the platform. - Anything else is omitted. When unsure, omit it and name it in the final report. @@ -156,9 +167,16 @@ Classification: Rules: - Only state facts found in the evidence. No invented features, dates or versions. -- One `~~~` subsection per item, written for integrators; link to the PR, issue or - the module's release note as RST links (`` `text `__ ``). -- Breaking changes state what integrators have to do. + Read the PR description before describing a PR; the title alone is not enough + (a PR may replace something instead of extending it). +- One `~~~` subsection per item with a short noun-phrase title, written for + integrators; link to the PR, issue or the module's release note as RST links + (`` `text `__ ``). Name new scripts, targets or settings that + integrators use. +- Order of the items: module set, integration and build, images and platforms, + tests, documentation; breaking changes of this repository before those of + the modules (one subsection per module, in table order). +- Breaking changes end with "Action required: ...". - Remove **Breaking Changes** only if there is none; never leave an `AI-FILL` marker behind (`grep -n AI-FILL ` must be empty). - Report items you left out although they looked relevant, and modules without @@ -180,7 +198,9 @@ Known limits of the evidence: - `grep -n "AI-FILL" ` is empty. - `releases.rst` lists the new note last. - Every `~~~` item and every bullet names a PR, issue or release note link. -- Every table row links to an existing tag (no stderr warnings). -- Platform scope version equals the `score_platform` row source. +- Every new module of the `--changes` table and every removed mechanism of this + repository is either in the note or named in the report. +- No stderr warnings of the generator; `(unreleased)` rows are named in the report. +- The scope links point to the release tag. - Docs build: `bazel run --lockfile_mode=error //:docs_shim -- --github_user=eclipse-score --github_repo=reference_integration` (heavy; optional locally, CI runs it). diff --git a/docs/s_core_v_1/releases/release_note_score_v0_10.rst b/docs/s_core_v_1/releases/release_note_score_v0_10.rst index bd3e07bf831..1eb181fbaee 100644 --- a/docs/s_core_v_1/releases/release_note_score_v0_10.rst +++ b/docs/s_core_v_1/releases/release_note_score_v0_10.rst @@ -13,7 +13,7 @@ # ******************************************************************************* S-CORE Platform v0.10 release note -================================== +==================================== .. document:: S-CORE v0.10 release note :id: doc__score_v010_release_note @@ -53,26 +53,45 @@ source organization, can not take over any liability for its content. ✨ New Features ---------------- +Module Tests in the Module Workspace (DR-008) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each integrated software module now runs its own unit tests and coverage in its +own Bazel workspace, against the dependency versions resolved by the reference +integration, with one CI job per module. The test arguments of each module are +kept in ``known_good.json`` (``metadata.test_args``), the shared build settings +in ``bazel_common/unified.bazelrc``. Locally: +``python3 scripts/module_tests.py run `` +(`PR #398 `__). + +Shared Image Packaging +~~~~~~~~~~~~~~~~~~~~~~ + +The QNX images are built with ``score_rules_imagefs`` 0.1.0. Application files +and configuration that both QNX images and the Linux image use are defined once +in ``//images/common/apps``, ``//images/qnx_common/build`` and +``//images/qnx_common/configs`` +(`PR #349 `__, +`PR #389 `__). + SBOM Generation ~~~~~~~~~~~~~~~ Added generation of a Software Bill of Materials for the product dependencies (SPDX and CycloneDX) and for the build tools (SPDX), see the "Software Bill of -Materials" section of the README (`PR #304 `__). +Materials" section of the README +(`PR #304 `__). Module Integration Tests ~~~~~~~~~~~~~~~~~~~~~~~~ -Added module integration tests for Lifecycle, Logging and Persistency to the -reference integration, so that their requirement coverage appears in the -verification reports (`PR #385 `__, `PR #387 `__, -`PR #388 `__). - -DR-008 Integration Workflow -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Added the DR-008 integration workflow (`PR #398 `__), building on the -resolved-dependency override mechanism introduced in v0.9. +The integration tests of Lifecycle, Logging and Persistency run in the reference +integration, and their results are linked to the requirements in the +verification reports +(`PR #385 `__, +`PR #387 `__). +The new infrastructure module Tools provides the pytest setup of the Persistency +tests (`PR #388 `__). Documentation and Verification Reports ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -80,13 +99,28 @@ Documentation and Verification Reports - The documentation of the Time module is part of the integrated documentation (`PR #348 `__). - The module verification reports of Logging and Lifecycle are linked from the - verification report overview (`PR #367 `__, `PR #373 `__). + verification report overview + (`PR #367 `__, + `PR #373 `__). - The Unit Tests and Coverage summaries are nested under the Platform - Verification Report (`PR #358 `__). + Verification Report + (`PR #358 `__). 💥 Breaking Changes -------------------- +Reference Integration: Resolved-Dependency Override Removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The resolved-dependency override introduced in v0.9 +(``scripts/known_good/resolved_dependencies.py`` and +``scripts/known_good/module_patches.py``) and the generated +``rust_coverage/BUILD`` were removed; module tests no longer run inside the +reference integration workspace +(`PR #398 `__). +Action required: run module tests with ``scripts/module_tests.py`` and keep +their test arguments in ``known_good.json`` (``metadata.test_args``). + Baselibs ~~~~~~~~ @@ -94,18 +128,21 @@ Baselibs is integrated at a commit after ``v0.2.14`` and contains breaking changes of ``v0.2.13`` and ``v0.2.14`` (see the `Baselibs release notes `__): -- ``score/datetime_converter`` was removed (`PR #471 `__). Use - POSIX or C library time APIs instead. +- ``score/datetime_converter`` was removed + (`PR #471 `__). Use POSIX + or C library time APIs instead. - The unused ``score/os`` libraries ``cpuid``, ``file_stream`` and ``make_dev`` were removed (`PR #535 `__). - The deprecated ``//src`` Bazel aliases and the string and atomic shims in - ``//score/memory`` were removed (`PR #598 `__). Use the + ``//score/memory`` were removed + (`PR #598 `__). Use the replacement targets. - ``score::flatbuffers::LoadBuffer`` now takes ``score::cpp::pmr::vector`` - instead of ``std::pmr::vector`` (`PR #603 `__). + instead of ``std::pmr::vector`` + (`PR #603 `__). -No usage of the removed APIs was found in the configuration of the reference -integration. Action required: adapt own code that uses them. +These are public APIs of Baselibs; the configuration of the reference +integration does not use them. Action required: adapt own code that uses them. 🎯 Reference Integration scope ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -237,13 +274,14 @@ integration. Action required: adapt own code that uses them. 🐞 Known Issues/Vulnerabilities and Bug Fixes ---------------------------------------------- -- See release notes of every module separately +- See the release notes of the individual modules. 🚀 Upgrade Instructions ------------------------ -- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from known_good.json. -- Versions can be found under: https://github.com/eclipse-score/reference_integration/blob/v0.10.0/known_good.json +- Increase to the newest Bazel registry versions: https://eclipse-score.github.io/bazel_registry_ui +- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from ``known_good.json``. +- The module versions of this release are listed in `known_good.json `__. 📧 Contact Information diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index b8269c056d6..bea9aa7f5ac 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -30,7 +30,6 @@ import os import re import subprocess -import sys import urllib.error import urllib.parse import urllib.request @@ -301,6 +300,31 @@ def render_release_notes(name: str, slug: str, old: grn.Entry, new: grn.Entry) - return "\n".join(out) +def render_removed_files(base: str, head: str) -> str: + """Files of this repository deleted or renamed between the two refs, per directory (breaking candidates).""" + result = subprocess.run( + ["git", "diff", "--name-status", "-M", "--diff-filter=DR", base, head, "--", ".", ":!docs", ":!patches"], + cwd=grn.REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + groups: dict[str, int] = {} + for parts in (line.split("\t") for line in result.stdout.splitlines()): + old_dir = os.path.dirname(parts[1]) or "." + label = ( + f"renamed {old_dir} -> {os.path.dirname(parts[2]) or '.'}" + if parts[0].startswith("R") + else f"removed from {old_dir}" + ) + if parts[0].startswith("R") and label.endswith(f"-> {old_dir}"): + label = f"renamed within {old_dir}" + groups[label] = groups.get(label, 0) + 1 + out = [f"## Reference integration: removed or renamed files {base} -> {head} (without docs/ and patches/)", ""] + out += [f"- {label} ({count} files)" for label, count in groups.items()] or ["None."] + return "\n".join(out) + "\n" + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--release", required=True) @@ -329,6 +353,7 @@ def main() -> None: ref_slug = f"eclipse-score/{grn.RELEASE_REPO}" head = args.release if grn._tag_exists(args.release) else "main" sections.append(render_module("Reference integration", ref_slug, args.origin, head, own_repo=True)) + sections.append(render_removed_files(args.origin, args.release if grn._tag_exists(args.release) else ref or "HEAD")) renamed_from = {new: old for old, new in grn.KEY_ALIASES.items()} for key, (group, entry) in current.items(): diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 72e845bd1f3..95c3d6392ea 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -418,19 +418,20 @@ def render(release: str, origin: str, date: str, status: str, groups: dict[str, parts += [_heading("Images", "-"), render_images(release, ref) + "\n"] unreleased = any(e.tag is None for entries in groups.values() for e in entries) - registry_hint = ( - "- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from known_good.json." + unreleased_hint = ( + "\n- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; " + "use the commit from ``known_good.json``." if unreleased - else "- Increase to newest bazel registry versions: https://eclipse-score.github.io/bazel_registry_ui" + else "" ) parts += [ _heading("Known Issues/Vulnerabilities and Bug Fixes", "-"), - "\n- See release notes of every module separately\n", + "\n- See the release notes of the individual modules.\n", _heading("Upgrade Instructions", "-"), f""" -{registry_hint} -- Versions can be found under: {release_url}/blob/{release}/known_good.json +- Increase to the newest Bazel registry versions: https://eclipse-score.github.io/bazel_registry_ui{unreleased_hint} +- The module versions of this release are listed in `known_good.json <{release_url}/blob/{release}/known_good.json>`__. """, _heading("Contact Information", "-"), From a3bbb991b6ca55d2fb286d6c7e4380378c4207bb Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:29:45 +0000 Subject: [PATCH 05/13] Release note: take the release date from the S-CORE milestone Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 8 ++-- .github/skills/release-note/SKILL.md | 15 ++++--- scripts/release_note/generate_release_note.py | 44 +++++++++++++------ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index cca5ce62e70..7b8970fbb8e 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -27,12 +27,14 @@ Read it first and follow it exactly. - DO NOT overwrite an existing `release_note_score_*.rst` without the user asking for it; for comparisons write to a different path. - DO NOT commit or push. -- ALWAYS surface generator warnings (missing tags, unreleased pins) to the user. +- ALWAYS surface generator warnings (missing tags, unreleased pins, missing + milestone) to the user. ## Workflow -1. Resolve `release` and `origin` tags from the argument. If the release tag - does not exist yet, ask for the planned release date. +1. Resolve `release` and `origin` tags from the argument. The release date + comes from the milestone `v.` in eclipse-score/score (the + generator reads it); do not ask for it and do not pass `--date`. 2. Prepare: `git fetch origin main --tags`; for an untagged release use `--known-good-ref origin/main` in both scripts. 3. Run the generator to create the skeleton and the `--changes` table. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index 67855f3455e..5f6a2eb89e0 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -15,7 +15,7 @@ runs the procedure; this skill holds the rules. | Section | Source | Written by | |---|---|---| -| Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args, tag date | script | +| Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args; Release Date = due date of the milestone `v.` in [eclipse-score/score](https://github.com/eclipse-score/score/milestones) | script | | Overview (fixed intro sentence) | release and origin tag | script | | Disclaimer | fixed text | script | | Changes to the Platform: New Features / Breaking Changes (platform-level only) | `collect_changes.py` (this repo's PRs, modules' own release notes, impact hints), `--changes` table | AI | @@ -54,7 +54,7 @@ When an image is added or renamed in `images/`, update `IMAGES`. ```bash # Skeleton (known_good.json is read from the release tag if it exists, else the working tree) python3 scripts/release_note/generate_release_note.py --release vX.Y.0 --origin vX.W.0 \ - --date YYYY-MM-DD --output docs/s_core_v_1/releases/release_note_score_vX_Y.rst + --output docs/s_core_v_1/releases/release_note_score_vX_Y.rst # Module version changes origin -> release (input for the narrative) python3 scripts/release_note/generate_release_note.py --release vX.Y.0 --origin vX.W.0 --changes @@ -66,6 +66,11 @@ Behaviour worth knowing: `version` pin maps to `v` or ``; a `hash` pin maps to a tag only if one points exactly at that commit, otherwise the row shows ` (unreleased)`, links the commit and has no release-notes link. +- The Release Date is always the due date of the milestone `v.` + (for `vX.Y.0`: milestone `vX.Y`) in eclipse-score/score. Do not pass `--date`; + it only exists as an override when the user asks for a different date. If + the milestone or its due date is missing, the script warns and writes `TBD`; + report this instead of guessing a date. - Warnings (`no tag for version ...`) on stderr mean a broken link. Fix the `known_good.json` pin or report it, never silence it. - Modules with `"enabled": false` are skipped. @@ -77,9 +82,9 @@ Behaviour worth knowing: The procedure is identical for existing and new releases; only the inputs differ. -1. **Inputs.** `release` and `origin` tag, output file - `docs/s_core_v_1/releases/release_note_score_v_.rst`, and the - planned release date if the release tag does not exist yet. +1. **Inputs.** `release` and `origin` tag and the output file + `docs/s_core_v_1/releases/release_note_score_v_.rst`. The + release date is taken from the milestone (see above). 2. **Prepare.** `git fetch origin main --tags`. For an upcoming release read `known_good.json` from `origin/main` with `--known-good-ref origin/main` (the working tree stays untouched). Set `GITHUB_TOKEN` for the GitHub API diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 95c3d6392ea..2c2d065b2f0 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -20,11 +20,14 @@ # Skeleton for an already tagged release (known_good.json is read from the tag) python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 - # Skeleton for an upcoming release (known_good.json is read from the working tree) - python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --date 2026-11-02 + # Skeleton for an upcoming release (known_good.json is read from origin/main) + python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --known-good-ref origin/main # Module version changes between the two releases python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 --changes + +The release date is the due date of the milestone ``v.`` in the +eclipse-score/score repository. """ from __future__ import annotations @@ -42,6 +45,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2] ORG_URL = "https://github.com/eclipse-score" RELEASE_REPO = "reference_integration" +# Repository whose milestones v. carry the planned platform release dates. +MILESTONE_REPO = "score" # known_good.json group -> section title; score_platform is moved from "tooling" to "target_sw". PLATFORM_MODULE = "score_platform" @@ -281,14 +286,28 @@ def _howto(ref: str | None, release: str, candidates: list[tuple[str, str | None return None -def release_assets(release: str) -> list[dict]: - url = f"https://api.github.com/repos/eclipse-score/{RELEASE_REPO}/releases/tags/{release}" - request = urllib.request.Request(url) +def _github_get(path: str) -> object: + request = urllib.request.Request(f"https://api.github.com{path}", headers={"Accept": "application/vnd.github+json"}) if token := os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PAT"): request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def milestone_date(release: str) -> str: + """Due date of the milestone v. in the S-CORE repository, e.g. v0.10.0 -> milestone v0.10.""" + title = "v" + ".".join(release.removeprefix("v").split(".")[:2]) + milestones = _github_get(f"/repos/eclipse-score/{MILESTONE_REPO}/milestones?state=all&per_page=100") + due = next((m["due_on"] for m in milestones if m["title"] == title and m["due_on"]), None) + if due is None: + print(f"warning: no milestone {title} with a due date in eclipse-score/{MILESTONE_REPO}", file=sys.stderr) + return "TBD" + return due[:10] + + +def release_assets(release: str) -> list[dict]: try: - with urllib.request.urlopen(request, timeout=30) as response: - return json.load(response).get("assets", []) + return _github_get(f"/repos/eclipse-score/{RELEASE_REPO}/releases/tags/{release}").get("assets", []) except OSError: return [] # release not published yet @@ -466,7 +485,10 @@ def main() -> None: "--known-good-ref", help="Git ref to read known_good.json from (default: release tag if it exists, else working tree)", ) - parser.add_argument("--date", help="Release date YYYY-MM-DD (default: tag date if the tag exists, else TBD)") + parser.add_argument( + "--date", + help="Override the release date YYYY-MM-DD (default: due date of the milestone in eclipse-score/score)", + ) parser.add_argument("--status", default="draft", choices=["draft", "valid"]) parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") parser.add_argument("--output", type=Path, help="Write to file instead of stdout") @@ -478,11 +500,7 @@ def main() -> None: if args.changes: text = render_changes(collect(load_known_good(args.origin)), groups) else: - date = args.date or ( - _git("log", "-1", "--format=%cs", f"{args.release}^{{commit}}").strip() - if _tag_exists(args.release) - else "TBD" - ) + date = args.date or milestone_date(args.release) text = render(args.release, args.origin, date, args.status, groups, ref) if args.output: From 20a8362a4d458733550333ee94a05f985a33077c Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:40:34 +0000 Subject: [PATCH 06/13] Release note: show draft or final state on top of the note Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 9 ++++-- .github/skills/release-note/SKILL.md | 19 +++++++++++- .../releases/release_note_score_v0_10.rst | 11 +++++-- scripts/release_note/generate_release_note.py | 29 +++++++++++++++++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index 7b8970fbb8e..e3628af1ea0 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -22,8 +22,10 @@ Read it first and follow it exactly. GitHub release notes (all via `collect_changes.py`). - List only platform-level new features and breaking changes (see the skill); module internals stay in the module release notes, which the note links. -- DO NOT edit generated parts (Overview, headings and icons, tables); only - replace the `AI-FILL` markers. +- DO NOT edit generated parts (status banner, Overview, headings and icons, + tables); only replace the `AI-FILL` markers. +- DO NOT pass `--status`: the note is a draft while the release tag does not + exist and final once it exists (see the skill, "Draft and final"). - DO NOT overwrite an existing `release_note_score_*.rst` without the user asking for it; for comparisons write to a different path. - DO NOT commit or push. @@ -44,7 +46,8 @@ Read it first and follow it exactly. 6. Append the new file to the `toctree` in `docs/s_core_v_1/releases/releases.rst` (explicit order, newest last). 7. Run the validation checks from the skill. -8. Report: output path, generator warnings, modules with unreleased pins, +8. Report: output path, state (draft or final), generator warnings, modules + with unreleased pins, items left out although they looked relevant, and anything you could not verify. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index 5f6a2eb89e0..fe1003d1c96 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -15,6 +15,7 @@ runs the procedure; this skill holds the rules. | Section | Source | Written by | |---|---|---| +| Status banner directly below the title: **DRAFT** (`.. warning::`, title suffix "(DRAFT)", `:status: draft`) or **FINAL** (`.. note::` with link to the GitHub release, `:status: valid`) | release tag exists? | script | | Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args; Release Date = due date of the milestone `v.` in [eclipse-score/score](https://github.com/eclipse-score/score/milestones) | script | | Overview (fixed intro sentence) | release and origin tag | script | | Disclaimer | fixed text | script | @@ -49,6 +50,22 @@ Image table: the image catalogue (`IMAGES` in the script) lists name, contains the image directory name, else `---`. How-to is the first candidate that exists at the ref, else `---`. When an image is added or renamed in `images/`, update `IMAGES`. +## Draft and final + +A note has one of two states, chosen by the generator from the release tag +(override with `--status draft|valid`): + +- **Draft** while the release is in progress (tag does not exist yet): the note + shows the current state, read from `origin/main`. Title suffix "(DRAFT)", a + warning banner on top and `:status: draft`. +- **Final** once the release is published (tag exists): the note shows the state + of the tag. No title suffix, a note banner with the link to the GitHub + release and `:status: valid`. + +When the tag is created, refresh the draft as described in "Run from scratch" +(regenerate without `--status`, keep the reviewed narrative); the banner, title +and status switch to final automatically. + ## Generator ```bash @@ -101,7 +118,7 @@ The procedure is identical for existing and new releases; only the inputs differ An existing note is never overwritten unless the user asks for it; to compare, write to another path. To refresh the tables of an existing draft (for example -after pin bumps, or with `--status valid` once the tag exists), generate to +after pin bumps, or to make it final once the tag exists), generate to another path and replace everything outside "Changes to the Platform"; keep the reviewed narrative. diff --git a/docs/s_core_v_1/releases/release_note_score_v0_10.rst b/docs/s_core_v_1/releases/release_note_score_v0_10.rst index 1eb181fbaee..786e06cfd5a 100644 --- a/docs/s_core_v_1/releases/release_note_score_v0_10.rst +++ b/docs/s_core_v_1/releases/release_note_score_v0_10.rst @@ -12,8 +12,15 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -S-CORE Platform v0.10 release note -==================================== +S-CORE Platform v0.10 release note (DRAFT) +============================================ + +.. warning:: + + **DRAFT - release in progress.** v0.10.0 is not released yet (planned for + 2026-11-02). This note shows the current state of the integration and changes + until the release is published. + .. document:: S-CORE v0.10 release note :id: doc__score_v010_release_note diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 2c2d065b2f0..c4a5942eea5 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -363,10 +363,27 @@ def _heading(text: str, char: str) -> str: return f"{text}\n{char * (len(text) + 2)}" +def status_banner(release: str, date: str, status: str) -> str: + if status == "draft": + return f""" +.. warning:: + + **DRAFT - release in progress.** {release} is not released yet (planned for + {date}). This note shows the current state of the integration and changes + until the release is published. +""" + return f""" +.. note:: + + **FINAL - released.** This is the release note of the published release + `{release} <{ORG_URL}/{RELEASE_REPO}/releases/tag/{release}>`__. +""" + + def render(release: str, origin: str, date: str, status: str, groups: dict[str, list[Entry]], ref: str | None) -> str: short = ".".join(release.removeprefix("v").split(".")[:2]) doc_id = "v" + short.replace(".", "") - title = f"S-CORE Platform v{short} release note" + title = f"S-CORE Platform v{short} release note" + (" (DRAFT)" if status == "draft" else "") release_url = f"{ORG_URL}/{RELEASE_REPO}" header = """.. @@ -386,6 +403,7 @@ def render(release: str, origin: str, date: str, status: str, groups: dict[str, parts = [ header, _heading(title, "="), + status_banner(release, date, status), f""" .. document:: S-CORE v{short} release note :id: doc__score_{doc_id}_release_note @@ -489,7 +507,11 @@ def main() -> None: "--date", help="Override the release date YYYY-MM-DD (default: due date of the milestone in eclipse-score/score)", ) - parser.add_argument("--status", default="draft", choices=["draft", "valid"]) + parser.add_argument( + "--status", + choices=["draft", "valid"], + help="draft = release in progress, valid = final (default: valid if the release tag exists, else draft)", + ) parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") parser.add_argument("--output", type=Path, help="Write to file instead of stdout") args = parser.parse_args() @@ -501,7 +523,8 @@ def main() -> None: text = render_changes(collect(load_known_good(args.origin)), groups) else: date = args.date or milestone_date(args.release) - text = render(args.release, args.origin, date, args.status, groups, ref) + status = args.status or ("valid" if _tag_exists(args.release) else "draft") + text = render(args.release, args.origin, date, status, groups, ref) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) From 71b3601f3516afcb16ca2640a2b0ff188c77d1b6 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:46:14 +0000 Subject: [PATCH 07/13] Release note: set the final state explicitly with --final Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 6 ++--- .github/skills/release-note/SKILL.md | 25 +++++++++---------- scripts/release_note/generate_release_note.py | 13 ++++------ 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index e3628af1ea0..b514c4f65d4 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -1,7 +1,7 @@ --- description: "Creates the S-CORE platform release note docs/s_core_v_1/releases/release_note_score_vX_Y.rst. Use when: preparing a release note for an upcoming platform release, regenerating one from known_good.json, or reviewing the module tables of an existing note. Module tables, platform scope and images are generated from known_good.json and the repository; the 'Changes to the Platform' chapter is written from this repository's PRs and the modules' own release notes (platform-level new features and breaking changes only)." name: Release Note -argument-hint: "release tag and origin tag, e.g. 'v0.10.0 from v0.9.0'" +argument-hint: "release tag and origin tag, e.g. 'v0.10.0 from v0.9.0' (add 'final' for the final note)" tools: [read, edit, search, execute, todo, web] user-invocable: true --- @@ -24,8 +24,8 @@ Read it first and follow it exactly. module internals stay in the module release notes, which the note links. - DO NOT edit generated parts (status banner, Overview, headings and icons, tables); only replace the `AI-FILL` markers. -- DO NOT pass `--status`: the note is a draft while the release tag does not - exist and final once it exists (see the skill, "Draft and final"). +- Pass `--final` only when the user explicitly asks for the final note; + otherwise the note is a draft (see the skill, "Draft and final"). - DO NOT overwrite an existing `release_note_score_*.rst` without the user asking for it; for comparisons write to a different path. - DO NOT commit or push. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index fe1003d1c96..5887ed186ce 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -15,7 +15,7 @@ runs the procedure; this skill holds the rules. | Section | Source | Written by | |---|---|---| -| Status banner directly below the title: **DRAFT** (`.. warning::`, title suffix "(DRAFT)", `:status: draft`) or **FINAL** (`.. note::` with link to the GitHub release, `:status: valid`) | release tag exists? | script | +| Status banner directly below the title: **DRAFT** (`.. warning::`, title suffix "(DRAFT)", `:status: draft`) or **FINAL** (`.. note::` with link to the release tag, `:status: valid`) | `--final` | script | | Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args; Release Date = due date of the milestone `v.` in [eclipse-score/score](https://github.com/eclipse-score/score/milestones) | script | | Overview (fixed intro sentence) | release and origin tag | script | | Disclaimer | fixed text | script | @@ -52,19 +52,18 @@ When an image is added or renamed in `images/`, update `IMAGES`. ## Draft and final -A note has one of two states, chosen by the generator from the release tag -(override with `--status draft|valid`): +A note has one of two states. The release note is part of the release and is +finalised before the tag is created, so the state is set explicitly: -- **Draft** while the release is in progress (tag does not exist yet): the note - shows the current state, read from `origin/main`. Title suffix "(DRAFT)", a - warning banner on top and `:status: draft`. -- **Final** once the release is published (tag exists): the note shows the state - of the tag. No title suffix, a note banner with the link to the GitHub - release and `:status: valid`. +- **Draft** (default) while the release is in progress: the note shows the + current state, read from `origin/main`. Title suffix "(DRAFT)", a warning + banner on top and `:status: draft`. +- **Final** with `--final`, only when the user says the note is final (content + frozen for the release). No title suffix, a note banner with the link to the + release tag and `:status: valid`. -When the tag is created, refresh the draft as described in "Run from scratch" -(regenerate without `--status`, keep the reviewed narrative); the banner, title -and status switch to final automatically. +To make a draft final, refresh it with `--final` as described in "Run from +scratch" and keep the reviewed narrative. ## Generator @@ -118,7 +117,7 @@ The procedure is identical for existing and new releases; only the inputs differ An existing note is never overwritten unless the user asks for it; to compare, write to another path. To refresh the tables of an existing draft (for example -after pin bumps, or to make it final once the tag exists), generate to +after pin bumps, or to make it final with `--final`), generate to another path and replace everything outside "Changes to the Platform"; keep the reviewed narrative. diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index c4a5942eea5..7d2dd2f2197 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -375,8 +375,8 @@ def status_banner(release: str, date: str, status: str) -> str: return f""" .. note:: - **FINAL - released.** This is the release note of the published release - `{release} <{ORG_URL}/{RELEASE_REPO}/releases/tag/{release}>`__. + **FINAL.** This is the release note of release + `{release} <{ORG_URL}/{RELEASE_REPO}/releases/tag/{release}>`__ ({date}). """ @@ -507,11 +507,8 @@ def main() -> None: "--date", help="Override the release date YYYY-MM-DD (default: due date of the milestone in eclipse-score/score)", ) - parser.add_argument( - "--status", - choices=["draft", "valid"], - help="draft = release in progress, valid = final (default: valid if the release tag exists, else draft)", - ) + # The note is part of the release and is finalised before the tag exists, so the state cannot be derived. + parser.add_argument("--final", action="store_true", help="Final release note (default: draft)") parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") parser.add_argument("--output", type=Path, help="Write to file instead of stdout") args = parser.parse_args() @@ -523,7 +520,7 @@ def main() -> None: text = render_changes(collect(load_known_good(args.origin)), groups) else: date = args.date or milestone_date(args.release) - status = args.status or ("valid" if _tag_exists(args.release) else "draft") + status = "valid" if args.final else "draft" text = render(args.release, args.origin, date, status, groups, ref) if args.output: From fe61a09ade770843129f0770dc950d7524682c2a Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 13:53:19 +0000 Subject: [PATCH 08/13] Add release-note prompt with draft/final parameter Signed-off-by: Anton Krivoborodov --- .github/prompts/release-note.prompt.md | 19 +++++++++++++++++++ .github/skills/release-note/SKILL.md | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 .github/prompts/release-note.prompt.md diff --git a/.github/prompts/release-note.prompt.md b/.github/prompts/release-note.prompt.md new file mode 100644 index 00000000000..25ed3683a1b --- /dev/null +++ b/.github/prompts/release-note.prompt.md @@ -0,0 +1,19 @@ +--- +description: "Create or refresh the S-CORE platform release note as draft or final." +name: "release-note" +argument-hint: "release=v0.10.0 origin=v0.9.0 state=draft|final" +agent: "Release Note" +--- +Create the platform release note for release `${input:release:Release tag, e.g. v0.10.0}` +compared to the previous release `${input:origin:Origin tag, e.g. v0.9.0}`. + +State: `${input:state:draft or final}` + +- `draft`: run the generator without `--final`. +- `final`: run the generator with `--final`. +- Any other value: stop and ask for `draft` or `final`. + +Follow the workflow of the agent and the [release-note skill](../skills/release-note/SKILL.md). +If the note file already exists, refresh it as described in the skill: regenerate +everything outside "Changes to the Platform" and keep the reviewed narrative, +unless I ask for a full rewrite. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index 5887ed186ce..8c1ca314bf2 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -97,6 +97,8 @@ Behaviour worth knowing: ## Run from scratch (any release, also for not yet tagged ones) The procedure is identical for existing and new releases; only the inputs differ. +In chat, the prompt `/release-note` (`.github/prompts/release-note.prompt.md`) +asks for release, origin and state (`draft` or `final`) and starts the agent. 1. **Inputs.** `release` and `origin` tag and the output file `docs/s_core_v_1/releases/release_note_score_v_.rst`. The From da741c6bc7605fa577b4f269dc04ca2f336032e0 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 14:27:43 +0000 Subject: [PATCH 09/13] Release note: address review findings (release selection by tag commit, one ref for all evidence, alias normalisation) Signed-off-by: Anton Krivoborodov --- scripts/release_note/collect_changes.py | 22 +++++++++++++------ scripts/release_note/generate_release_note.py | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index bea9aa7f5ac..a7dd12fba00 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -275,15 +275,22 @@ def impact_hints(text: str, module_key: str) -> list[str]: return hints[:8] +def in_range(slug: str, old_ref: str, tag: str, new_ref: str) -> bool: + """True if the tag commit is after old_ref and contained in new_ref (excludes other maintenance branches).""" + after_old = api(f"/repos/{slug}/compare/{old_ref}...{tag}")["status"] == "ahead" + return after_old and api(f"/repos/{slug}/compare/{tag}...{new_ref}")["status"] in ("ahead", "identical") + + def render_release_notes(name: str, slug: str, old: grn.Entry, new: grn.Entry) -> str: - start, end = ref_date(slug, old.ref), ref_date(slug, new.ref) + start = ref_date(slug, old.ref) + # A release published before the old pin cannot contain later commits; the range itself is decided by the tag commit. releases = [ r for r in paged(f"/repos/{slug}/releases") if r.get("published_at") - and start < r["published_at"] <= end[:10] + "T23:59:59Z" + and r["published_at"] > start and not r["draft"] - and r["tag_name"] != old.tag # the origin release is already part of the previous platform release + and in_range(slug, old.ref, r["tag_name"], new.ref) ] out = [f"## {name}: {old.version_label} -> {new.version_label} ({slug}), module release notes", ""] if not releases: @@ -340,20 +347,21 @@ def main() -> None: parser.add_argument("--include-infra", action="store_true", help="Also cover the Associated Infrastructure Modules") parser.add_argument( "--known-good-ref", - help="Git ref to read known_good.json from (default: release tag if it exists, else the working tree), e.g. origin/main", + help="Git ref for known_good.json and the evidence of this repository (default: release tag if it exists, else origin/main)", ) parser.add_argument("--output", type=Path) args = parser.parse_args() - ref = args.known_good_ref or (args.release if grn._tag_exists(args.release) else None) + ref = args.known_good_ref or (args.release if grn._tag_exists(args.release) else "origin/main") current = {e.key: (g, e) for g, es in grn.collect(grn.load_known_good(ref)).items() for e in es} previous = {e.key: e for es in grn.collect(grn.load_known_good(args.origin)).values() for e in es} sections = [] ref_slug = f"eclipse-score/{grn.RELEASE_REPO}" - head = args.release if grn._tag_exists(args.release) else "main" + # One commit for all evidence of this repository, the same one known_good.json is read from. + head = grn._git("rev-parse", f"{ref}^{{commit}}").strip() sections.append(render_module("Reference integration", ref_slug, args.origin, head, own_repo=True)) - sections.append(render_removed_files(args.origin, args.release if grn._tag_exists(args.release) else ref or "HEAD")) + sections.append(render_removed_files(args.origin, head)) renamed_from = {new: old for old, new in grn.KEY_ALIASES.items()} for key, (group, entry) in current.items(): diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 7d2dd2f2197..189a6372376 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -482,7 +482,7 @@ def render(release: str, origin: str, date: str, status: str, groups: dict[str, def render_changes(origin: dict[str, list[Entry]], current: dict[str, list[Entry]]) -> str: old = {KEY_ALIASES.get(e.key, e.key): e for entries in origin.values() for e in entries} - new = {e.key: e for entries in current.values() for e in entries} + new = {KEY_ALIASES.get(e.key, e.key): e for entries in current.values() for e in entries} lines = ["| Module | Previous | Current |", "|---|---|---|"] for key in sorted(old.keys() | new.keys()): o, n = old.get(key), new.get(key) From 76740051af65648c0829f025d770ff9572f9278e Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 27 Oct 2026 15:50:40 +0000 Subject: [PATCH 10/13] Release note: run on the release candidate branch; drop the v0.10 note from this PR Signed-off-by: Anton Krivoborodov --- .github/agents/release-note.agent.md | 9 +- .github/skills/release-note/SKILL.md | 27 +- .../releases/release_note_score_v0_10.rst | 299 ------------------ docs/s_core_v_1/releases/releases.rst | 11 +- scripts/release_note/collect_changes.py | 4 +- scripts/release_note/generate_release_note.py | 4 +- 6 files changed, 26 insertions(+), 328 deletions(-) delete mode 100644 docs/s_core_v_1/releases/release_note_score_v0_10.rst diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index b514c4f65d4..e70d6a70ac5 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -37,14 +37,15 @@ Read it first and follow it exactly. 1. Resolve `release` and `origin` tags from the argument. The release date comes from the milestone `v.` in eclipse-score/score (the generator reads it); do not ask for it and do not pass `--date`. -2. Prepare: `git fetch origin main --tags`; for an untagged release use - `--known-good-ref origin/main` in both scripts. +2. Prepare: work on the release candidate branch of the release (ask for it if + the current branch is `main`); `git pull` and `git fetch --tags`. Do not + pass `--known-good-ref`; both scripts read the checked-out branch. 3. Run the generator to create the skeleton and the `--changes` table. 4. Run `collect_changes.py` and classify into platform-level new features and breaking changes as described in the skill (use the impact hints). 5. Replace both `.. AI-FILL:` markers (New Features, Breaking Changes). -6. Append the new file to the `toctree` in - `docs/s_core_v_1/releases/releases.rst` (explicit order, newest last). +6. Make sure the new note is part of the `toctree` in + `docs/s_core_v_1/releases/releases.rst`. 7. Run the validation checks from the skill. 8. Report: output path, state (draft or final), generator warnings, modules with unreleased pins, diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index 8c1ca314bf2..b323a4ce349 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -56,7 +56,7 @@ A note has one of two states. The release note is part of the release and is finalised before the tag is created, so the state is set explicitly: - **Draft** (default) while the release is in progress: the note shows the - current state, read from `origin/main`. Title suffix "(DRAFT)", a warning + current state of the release branch. Title suffix "(DRAFT)", a warning banner on top and `:status: draft`. - **Final** with `--final`, only when the user says the note is final (content frozen for the release). No title suffix, a note banner with the link to the @@ -68,7 +68,7 @@ scratch" and keep the reviewed narrative. ## Generator ```bash -# Skeleton (known_good.json is read from the release tag if it exists, else the working tree) +# Skeleton (known_good.json is read from the release tag if it exists, else the checked-out release branch) python3 scripts/release_note/generate_release_note.py --release vX.Y.0 --origin vX.W.0 \ --output docs/s_core_v_1/releases/release_note_score_vX_Y.rst @@ -103,18 +103,21 @@ asks for release, origin and state (`draft` or `final`) and starts the agent. 1. **Inputs.** `release` and `origin` tag and the output file `docs/s_core_v_1/releases/release_note_score_v_.rst`. The release date is taken from the milestone (see above). -2. **Prepare.** `git fetch origin main --tags`. For an upcoming release read - `known_good.json` from `origin/main` with `--known-good-ref origin/main` - (the working tree stays untouched). Set `GITHUB_TOKEN` for the GitHub API - calls (anonymous access is rate limited); never print it. +2. **Prepare.** Check out the release candidate branch of the release and + update it (`git pull`, `git fetch --tags`). The note is created and + committed only there; it reaches `main` when the release branch is merged + back after the release. Never add a release note on `main` directly, it + would conflict with that merge. Both scripts read the checked-out state by + default; the release branch must be pushed, because the collector compares + commits through the GitHub API. Set `GITHUB_TOKEN` for the GitHub API calls + (anonymous access is rate limited); never print it. 3. **Skeleton.** Run the generator (see above). Check the stderr warnings. -4. **Evidence.** Run `collect_changes.py` with the same `--release`, `--origin` - and `--known-good-ref`, plus the `--changes` table. Runtime is a few minutes. +4. **Evidence.** Run `collect_changes.py` with the same `--release` and + `--origin`, plus the `--changes` table. Runtime is a few minutes. 5. **Narrative.** Replace the two `AI-FILL` markers (New Features, Breaking Changes) with the rules below. Nothing else in the file is written by hand. -6. **Index.** `docs/s_core_v_1/releases/releases.rst` lists the notes in an - explicit order (oldest first); append the new file at the end of that - `toctree`. +6. **Index.** Make sure the new note is part of the `toctree` in + `docs/s_core_v_1/releases/releases.rst` on the release branch. 7. **Validate** (checklist below) and report. An existing note is never overwritten unless the user asks for it; to compare, @@ -219,7 +222,7 @@ Known limits of the evidence: ## Validation - `grep -n "AI-FILL" ` is empty. -- `releases.rst` lists the new note last. +- The new note is part of the `toctree` in `releases.rst`. - Every `~~~` item and every bullet names a PR, issue or release note link. - Every new module of the `--changes` table and every removed mechanism of this repository is either in the note or named in the report. diff --git a/docs/s_core_v_1/releases/release_note_score_v0_10.rst b/docs/s_core_v_1/releases/release_note_score_v0_10.rst deleted file mode 100644 index 786e06cfd5a..00000000000 --- a/docs/s_core_v_1/releases/release_note_score_v0_10.rst +++ /dev/null @@ -1,299 +0,0 @@ -.. - # ******************************************************************************* - # Copyright (c) 2026 Contributors to the Eclipse Foundation - # - # See the NOTICE file(s) distributed with this work for additional - # information regarding copyright ownership. - # - # This program and the accompanying materials are made available under the - # terms of the Apache License Version 2.0 which is available at - # https://www.apache.org/licenses/LICENSE-2.0 - # - # SPDX-License-Identifier: Apache-2.0 - # ******************************************************************************* - -S-CORE Platform v0.10 release note (DRAFT) -============================================ - -.. warning:: - - **DRAFT - release in progress.** v0.10.0 is not released yet (planned for - 2026-11-02). This note shows the current state of the integration and changes - until the release is published. - - -.. document:: S-CORE v0.10 release note - :id: doc__score_v010_release_note - :status: draft - :safety: QM - :security: YES - :realizes: wp__platform_sw_release_note - :version: 1 - -| **Platform Name:** S-CORE -| **Release Tag:** v0.10.0 -| **Origin Release Tag:** v0.9.0 -| **Release Date:** 2026-11-02 - - -📋 Overview -^^^^^^^^^^^^ - -This document provides an overview of the changes, improvements, and bug fixes -included in the software platform release version v0.10.0 as compared to the -previous platform release (v0.9.0). - - -📜 Disclaimer --------------- - -This release note does not "release for production", as it does not come with a -safety argumentation and a performed safety assessment. -The work products compiled in the safety package are created with care according -to a process satisfying standards, but the project, being a non-profit and open -source organization, can not take over any liability for its content. - - -🔄 Changes to the Platform -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -✨ New Features ----------------- - -Module Tests in the Module Workspace (DR-008) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Each integrated software module now runs its own unit tests and coverage in its -own Bazel workspace, against the dependency versions resolved by the reference -integration, with one CI job per module. The test arguments of each module are -kept in ``known_good.json`` (``metadata.test_args``), the shared build settings -in ``bazel_common/unified.bazelrc``. Locally: -``python3 scripts/module_tests.py run `` -(`PR #398 `__). - -Shared Image Packaging -~~~~~~~~~~~~~~~~~~~~~~ - -The QNX images are built with ``score_rules_imagefs`` 0.1.0. Application files -and configuration that both QNX images and the Linux image use are defined once -in ``//images/common/apps``, ``//images/qnx_common/build`` and -``//images/qnx_common/configs`` -(`PR #349 `__, -`PR #389 `__). - -SBOM Generation -~~~~~~~~~~~~~~~ - -Added generation of a Software Bill of Materials for the product dependencies -(SPDX and CycloneDX) and for the build tools (SPDX), see the "Software Bill of -Materials" section of the README -(`PR #304 `__). - -Module Integration Tests -~~~~~~~~~~~~~~~~~~~~~~~~ - -The integration tests of Lifecycle, Logging and Persistency run in the reference -integration, and their results are linked to the requirements in the -verification reports -(`PR #385 `__, -`PR #387 `__). -The new infrastructure module Tools provides the pytest setup of the Persistency -tests (`PR #388 `__). - -Documentation and Verification Reports -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- The documentation of the Time module is part of the integrated documentation - (`PR #348 `__). -- The module verification reports of Logging and Lifecycle are linked from the - verification report overview - (`PR #367 `__, - `PR #373 `__). -- The Unit Tests and Coverage summaries are nested under the Platform - Verification Report - (`PR #358 `__). - -💥 Breaking Changes --------------------- - -Reference Integration: Resolved-Dependency Override Removed -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The resolved-dependency override introduced in v0.9 -(``scripts/known_good/resolved_dependencies.py`` and -``scripts/known_good/module_patches.py``) and the generated -``rust_coverage/BUILD`` were removed; module tests no longer run inside the -reference integration workspace -(`PR #398 `__). -Action required: run module tests with ``scripts/module_tests.py`` and keep -their test arguments in ``known_good.json`` (``metadata.test_args``). - -Baselibs -~~~~~~~~ - -Baselibs is integrated at a commit after ``v0.2.14`` and contains breaking -changes of ``v0.2.13`` and ``v0.2.14`` (see the `Baselibs release notes -`__): - -- ``score/datetime_converter`` was removed - (`PR #471 `__). Use POSIX - or C library time APIs instead. -- The unused ``score/os`` libraries ``cpuid``, ``file_stream`` and ``make_dev`` - were removed (`PR #535 `__). -- The deprecated ``//src`` Bazel aliases and the string and atomic shims in - ``//score/memory`` were removed - (`PR #598 `__). Use the - replacement targets. -- ``score::flatbuffers::LoadBuffer`` now takes ``score::cpp::pmr::vector`` - instead of ``std::pmr::vector`` - (`PR #603 `__). - -These are public APIs of Baselibs; the configuration of the reference -integration does not use them. Action required: adapt own code that uses them. - -🎯 Reference Integration scope -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- **Version:** ``v0.10.0`` -- **Source / tag:** `Reference integration `__ -- **Release notes:** `Reference integration release notes `__ -- **Platform Verification Report:** :need:`doc__platform_verification_report_latest` - - -🧩 Integrated Software Modules -------------------------------- - -.. list-table:: - :header-rows: 1 - :widths: 25 15 25 35 - - * - Name - - Version - - Release notes - - Module verification report - * - `Platform `__ - - ``v0.7.2`` - - `Release notes `__ - - --- - * - `Baselibs `__ - - ``ee40fec (unreleased)`` - - --- - - :doc:`Verification report ` - * - `Communication `__ - - ``313f117 (unreleased)`` - - --- - - --- - * - `Persistency `__ - - ``961c0a0 (unreleased)`` - - --- - - :doc:`Verification report ` - * - `Kyron `__ - - ``90fb902 (unreleased)`` - - --- - - --- - * - `Lifecycle `__ - - ``85da216 (unreleased)`` - - --- - - :doc:`Verification report ` - * - `Logging `__ - - ``3d98966 (unreleased)`` - - --- - - :doc:`Verification report ` - * - `Time `__ - - ``3723ce6 (unreleased)`` - - --- - - --- - * - `Configuration Management `__ - - ``d0dab01 (unreleased)`` - - --- - - --- - -🔧 Associated Infrastructure Modules -------------------------------------- - -.. list-table:: - :header-rows: 1 - :widths: 30 25 45 - - * - Name - - Version - - Release notes - * - `Crates `__ - - ``v0.0.11`` - - `Release notes `__ - * - `ITF (Integration Testing Framework) `__ - - ``v0.6.0`` - - `Release notes `__ - * - `Tools `__ - - ``4b48cec (unreleased)`` - - --- - * - `Tooling `__ - - ``v2.2.3`` - - `Release notes `__ - * - `Bazel Platforms `__ - - ``v1.1.0`` - - `Release notes `__ - * - `Test Scenarios `__ - - ``v0.4.1`` - - `Release notes `__ - * - `Docs-as-code `__ - - ``v8.4.0`` - - `Release notes `__ - * - `Process description `__ - - ``v2.1.2`` - - `Release notes `__ - * - `Bazel CPP Toolchain `__ - - ``v1.0.3`` - - `Release notes `__ - -💿 Images ----------- - -.. list-table:: - :header-rows: 1 - :widths: 30 15 15 40 - - * - Name - - Tarball - - How-to - - Description - * - Reference Linux x86_64 image - - --- - - `How-to `__ - - Linux x86_64 Docker image bundling the S-CORE showcases. - * - Reference QNX x86_64 image - - --- - - `How-to `__ - - QNX x86_64 image, run in QEMU. - * - Reference QNX aarch64 image - - --- - - `How-to `__ - - QNX 8.0 aarch64 image, run in QEMU or on Raspberry Pi 4/5. - * - Reference Red Hat AutoSD Linux image (Experimental) - - --- - - `How-to `__ - - Red Hat AutoSD x86_64 image. - * - Reference Elektrobit corbos Linux for Safety Applications image (Experimental) - - --- - - `How-to `__ - - Elektrobit corbos Linux for Safety Applications (aarch64) image. - -🐞 Known Issues/Vulnerabilities and Bug Fixes ----------------------------------------------- - -- See the release notes of the individual modules. - -🚀 Upgrade Instructions ------------------------- - -- Increase to the newest Bazel registry versions: https://eclipse-score.github.io/bazel_registry_ui -- Modules shown as *(unreleased)* are pinned to a commit and have no registry version; use the commit from ``known_good.json``. -- The module versions of this release are listed in `known_good.json `__. - - -📧 Contact Information ------------------------ - -For any questions or support, please contact the *Project leads* or raise an issue/discussion. -https://projects.eclipse.org/projects/automotive.score - diff --git a/docs/s_core_v_1/releases/releases.rst b/docs/s_core_v_1/releases/releases.rst index 9788156b17a..af111240b0a 100644 --- a/docs/s_core_v_1/releases/releases.rst +++ b/docs/s_core_v_1/releases/releases.rst @@ -15,15 +15,8 @@ Releases ============ -.. Explicit order: a glob would sort v0_10 before v0_5. - .. toctree:: :titlesonly: + :glob: - release_note_score_v0_5_alpha - release_note_score_v0_5_beta - release_note_score_v0_6 - release_note_score_v0_7 - release_note_score_v0_8 - release_note_score_v0_9 - release_note_score_v0_10 + * diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index a7dd12fba00..f40c32b93a8 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -347,12 +347,12 @@ def main() -> None: parser.add_argument("--include-infra", action="store_true", help="Also cover the Associated Infrastructure Modules") parser.add_argument( "--known-good-ref", - help="Git ref for known_good.json and the evidence of this repository (default: release tag if it exists, else origin/main)", + help="Git ref for known_good.json and the evidence of this repository (default: release tag if it exists, else HEAD; must be pushed)", ) parser.add_argument("--output", type=Path) args = parser.parse_args() - ref = args.known_good_ref or (args.release if grn._tag_exists(args.release) else "origin/main") + ref = args.known_good_ref or (args.release if grn._tag_exists(args.release) else "HEAD") current = {e.key: (g, e) for g, es in grn.collect(grn.load_known_good(ref)).items() for e in es} previous = {e.key: e for es in grn.collect(grn.load_known_good(args.origin)).values() for e in es} diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 189a6372376..608e898b7d9 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -20,8 +20,8 @@ # Skeleton for an already tagged release (known_good.json is read from the tag) python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 - # Skeleton for an upcoming release (known_good.json is read from origin/main) - python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 --known-good-ref origin/main + # Skeleton for an upcoming release, run on its release candidate branch (known_good.json of the checkout) + python3 scripts/release_note/generate_release_note.py --release v0.10.0 --origin v0.9.0 # Module version changes between the two releases python3 scripts/release_note/generate_release_note.py --release v0.8.0 --origin v0.7.0 --changes From 7cb7f4430561a21f3ab7006e77eee8aafea8a451 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Wed, 28 Oct 2026 10:29:47 +0000 Subject: [PATCH 11/13] Release note: generate an architecture diagram with every skeleton run The generator renders docs/s_core_v_1/releases/_assets/architecture_vX_Y.svg from scratch (scripts/release_note/architecture_diagram.py) and embeds it in the Overview: platform/infrastructure/tooling modules, the integrated software modules with their components (Sphinx-Needs comp/mod from needs.json, --needs) and the target images. Same inputs give a byte-identical SVG. Skill and agent document the diagram as a generated part. --- .github/agents/release-note.agent.md | 11 +- .github/skills/release-note/SKILL.md | 49 ++- scripts/release_note/architecture_diagram.py | 396 ++++++++++++++++++ scripts/release_note/generate_release_note.py | 50 +++ 4 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 scripts/release_note/architecture_diagram.py diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index e70d6a70ac5..7d54cb0155c 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -23,7 +23,8 @@ Read it first and follow it exactly. - List only platform-level new features and breaking changes (see the skill); module internals stay in the module release notes, which the note links. - DO NOT edit generated parts (status banner, Overview, headings and icons, - tables); only replace the `AI-FILL` markers. + tables, architecture diagram SVG); only replace the `AI-FILL` markers. The + diagram is regenerated from scratch with every skeleton run. - Pass `--final` only when the user explicitly asks for the final note; otherwise the note is a draft (see the skill, "Draft and final"). - DO NOT overwrite an existing `release_note_score_*.rst` without the user @@ -40,15 +41,17 @@ Read it first and follow it exactly. 2. Prepare: work on the release candidate branch of the release (ask for it if the current branch is `main`); `git pull` and `git fetch --tags`. Do not pass `--known-good-ref`; both scripts read the checked-out branch. -3. Run the generator to create the skeleton and the `--changes` table. +3. Run the generator to create the skeleton (this also renders the + architecture diagram) and the `--changes` table. 4. Run `collect_changes.py` and classify into platform-level new features and breaking changes as described in the skill (use the impact hints). 5. Replace both `.. AI-FILL:` markers (New Features, Breaking Changes). 6. Make sure the new note is part of the `toctree` in `docs/s_core_v_1/releases/releases.rst`. 7. Run the validation checks from the skill. -8. Report: output path, state (draft or final), generator warnings, modules - with unreleased pins, +8. Report: output path and diagram path, state (draft or final), generator + warnings, modules with unreleased pins, modules shown with "no components + documented" in the diagram, items left out although they looked relevant, and anything you could not verify. diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index b323a4ce349..a9e68cc1ac1 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -18,6 +18,7 @@ runs the procedure; this skill holds the rules. | Status banner directly below the title: **DRAFT** (`.. warning::`, title suffix "(DRAFT)", `:status: draft`) or **FINAL** (`.. note::` with link to the release tag, `:status: valid`) | `--final` | script | | Header (`document` directive, Platform Name, Release Tag, Origin Release Tag, Release Date) | CLI args; Release Date = due date of the milestone `v.` in [eclipse-score/score](https://github.com/eclipse-score/score/milestones) | script | | Overview (fixed intro sentence) | release and origin tag | script | +| Architecture diagram (figure in the Overview, SVG in `docs/s_core_v_1/releases/_assets/architecture_vX_Y.svg`) | `known_good.json` (release and origin), `images/`, components from `needs.json` | script (`architecture_diagram.py`) | | Disclaimer | fixed text | script | | Changes to the Platform: New Features / Breaking Changes (platform-level only) | `collect_changes.py` (this repo's PRs, modules' own release notes, impact hints), `--changes` table | AI | | Reference Integration scope: Version, Source / tag, Release notes, Platform Verification Report (bullets) | release tag | script | @@ -94,6 +95,45 @@ Behaviour worth knowing: - The two narrative sections (New Features, Breaking Changes) are emitted as `.. AI-FILL: ` comments. +## Architecture diagram + +Every skeleton run renders the diagram from scratch +(`scripts/release_note/architecture_diagram.py`, called by the generator) to +`docs/s_core_v_1/releases/_assets/architecture_v_.svg` and embeds +it with `.. figure::` in the Overview. The SVG is a generated file: never edit +it by hand and never keep an old one; regenerate it with the skeleton. + +Content: + +- Header: release, DRAFT/FINAL badge (`--final`), release date, origin and key + figures (software modules, components, infrastructure modules, images). +- Left column over the full height: platform, infrastructure and tooling + modules (`score_platform` + group `tooling`). +- Right: the integrated software modules as tiles side by side (no layering), + each with version, the version in the origin release ("was ...") and its + components as chips; below them the target images (same catalogue as the + image table, dashed = experimental). +- Colour bar: green = released tag, orange = commit pin; NEW = not in the origin + release. + +Components come from the Sphinx-Needs of a `needs.json` (`--needs`, default: +the published docs of `main`): every `comp` need whose source lies in +`modules//`, plus the `includes` of the module's `mod` need (a `mod` +need outside the module docs, for example in `score_platform`, is matched by its +title). Modules without such needs show "no components documented"; report +them, do not add components by hand. If the `needs.json` cannot be read the +generator warns and draws the tiles without components. + +The diagram is a pure function of its inputs: `known_good.json` at the release +ref and at the origin tag, `images/` at the release ref, the `needs.json`, the +milestone date and the state (`--final`). There is no randomness, no timestamp +and no layout state; the same inputs give a byte-identical SVG. To check, run +the generator twice to a scratch output and `cmp` the SVG. The default +`needs.json` is the published `main` docs, which change over time; pass +`--needs ` to reproduce a diagram with a fixed component list. +Colours follow the S-CORE docs theme (header `#2D1942`); change them only in +`architecture_diagram.py`. + ## Run from scratch (any release, also for not yet tagged ones) The procedure is identical for existing and new releases; only the inputs differ. @@ -111,7 +151,8 @@ asks for release, origin and state (`draft` or `final`) and starts the agent. default; the release branch must be pushed, because the collector compares commits through the GitHub API. Set `GITHUB_TOKEN` for the GitHub API calls (anonymous access is rate limited); never print it. -3. **Skeleton.** Run the generator (see above). Check the stderr warnings. +3. **Skeleton.** Run the generator (see above); it also writes the + architecture diagram (see "Architecture diagram"). Check the stderr warnings. 4. **Evidence.** Run `collect_changes.py` with the same `--release` and `--origin`, plus the `--changes` table. Runtime is a few minutes. 5. **Narrative.** Replace the two `AI-FILL` markers (New Features, Breaking @@ -124,7 +165,8 @@ An existing note is never overwritten unless the user asks for it; to compare, write to another path. To refresh the tables of an existing draft (for example after pin bumps, or to make it final with `--final`), generate to another path and replace everything outside "Changes to the Platform"; keep the -reviewed narrative. +reviewed narrative. The diagram SVG is always overwritten by the run, so it +matches the refreshed tables. Re-running with the same inputs gives the same tables and chapter structure. The narrative may be worded differently, but it has to name the same items as @@ -222,6 +264,9 @@ Known limits of the evidence: ## Validation - `grep -n "AI-FILL" ` is empty. +- The Overview contains the `.. figure::` of the diagram and the SVG + `docs/s_core_v_1/releases/_assets/architecture_v_.svg` was + written by this run (it is committed together with the note). - The new note is part of the `toctree` in `releases.rst`. - Every `~~~` item and every bullet names a PR, issue or release note link. - Every new module of the `--changes` table and every removed mechanism of this diff --git a/scripts/release_note/architecture_diagram.py b/scripts/release_note/architecture_diagram.py new file mode 100644 index 00000000000..22fbd2f567a --- /dev/null +++ b/scripts/release_note/architecture_diagram.py @@ -0,0 +1,396 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Architecture diagram (SVG) of the reference integration for the platform release note. + +Rendered by generate_release_note.py together with the skeleton; never edited by hand. +Modules and images come from known_good.json and the repository, components from the +Sphinx-Needs ``comp``/``mod`` needs of a needs.json. +""" + +from __future__ import annotations + +import json +import re +import urllib.request +from pathlib import Path +from xml.sax.saxutils import escape + +import generate_release_note as grn + +W = 1280 +M = 40 +NAVY = "#0b1f3a" +INK = "#1d2b3f" +MUTED = "#5b6b80" +FAINT = "#9aa8bb" +RELEASED = "#0f9d8a" +UNRELEASED = "#e39a2d" +NEW = "#6d4aff" +# Header bar of the S-CORE docs theme (--pst-color-secondary in docs-as-code score.css) +HEADER = "#2d1942" +HEADER_TEXT = "#d8cce6" +HEADER_FAINT = "#a996bf" +FONT = "Inter, 'Segoe UI', Helvetica, Arial, sans-serif" +MONO = "'JetBrains Mono', Menlo, Consolas, monospace" + +# images/ -> (operating system, architecture) +IMAGE_LABELS = { + "linux_x86_64": ("Linux", "x86_64"), + "qnx_x86_64": ("QNX 8.0", "x86_64"), + "qnx_aarch64": ("QNX 8.0", "aarch64"), + "autosd": ("Red Hat AutoSD", "x86_64"), + "ebclfsa_aarch64": ("EB corbos Linux", "aarch64"), +} + + +def text_width(s: str, size: float, weight: int = 400) -> float: + return len(s) * size * (0.64 if weight >= 600 else 0.56) + + +def fit(s: str, size: float, weight: int, available: float, min_size: float = 11) -> tuple[str, float]: + while size > min_size and text_width(s, size, weight) > available: + size -= 0.5 + while len(s) > 4 and text_width(s, size, weight) > available: + s = s[:-2] + "\u2026" + return s, size + + +def wrap(s: str, size: float, weight: int, available: float) -> tuple[list[str], float]: + """Split a too-long name into two balanced lines instead of shrinking it.""" + if text_width(s, size, weight) <= available or " " not in s: + label, size = fit(s, size, weight, available) + return [label], size + words = s.split() + cut = min(range(1, len(words)), key=lambda i: max(len(" ".join(words[:i])), len(" ".join(words[i:])))) + lines = [" ".join(words[:cut]), " ".join(words[cut:])] + size = min(fit(line, size, weight, available)[1] for line in lines) + return [fit(line, size, weight, available)[0] for line in lines], size + + +def text(x, y, s, size=14, weight=400, fill=INK, anchor="start", family=FONT, extra=""): + return ( + f'{escape(s)}' + ) + + +def pill(x_right, y, label, color, h=18): + w = 12 + 7 * len(label) + return f'' + text( + x_right - w / 2, y + h / 2 + 4, label, 10, 700, "#fff", "middle", extra='letter-spacing="0.8"' + ) + + +def card_base(x, y, w, h, accent): + return [ + f'', + f'', + f'', + ] + + +def tile(x, y, w, h, entry: grn.Entry, is_new: bool, note: str): + accent = RELEASED if entry.tag else UNRELEASED + out = card_base(x, y, w, h, accent) + lines, size = wrap(entry.name, 16, 700, w - 34 - (52 if is_new else 0)) + for i, line in enumerate(lines): + out.append(text(x + 20, y + 30 + i * (size + 5), line, size, 700, NAVY)) + version = entry.tag or entry.pin[:7] + out.append(text(x + 20, y + h - 56, version, 18, 700, INK if entry.tag else accent, family=MONO)) + if note: + out.append(text(x + 20, y + h - 38, note, 10, 400, FAINT, family=MONO)) + out.append( + f'' + ) + repo = entry.repo_url.rstrip("/").rsplit("/", 1)[-1] + out.append(text(x + 20, y + h - 11, fit(repo, 10, 400, w - 34)[0], 10, 400, FAINT, family=MONO)) + if is_new: + out.append(pill(x + w - 10, y + 10, "NEW", NEW)) + return "\n".join(out) + + +def row_card(x, y, w, h, entry: grn.Entry, is_new: bool): + accent = RELEASED if entry.tag else UNRELEASED + out = card_base(x, y, w, h, accent) + version = entry.tag or entry.pin[:7] + name = re.sub(r"\s*\(.*\)$", "", entry.name) + label, size = fit(name, 13, 600, w - 40 - text_width(version, 11) - (42 if is_new else 0)) + out.append(text(x + 18, y + h / 2 + 5, label, size, 600)) + if is_new: + out.append(pill(x + 18 + text_width(label, size, 600) + 41, y + (h - 18) / 2, "NEW", NEW)) + out.append(text(x + w - 12, y + h / 2 + 4, version, 11, 400, MUTED, "end", family=MONO)) + return "\n".join(out) + + +def section(x, y, w, h, title_lines, subtitle="", stacked=False): + """stacked: subtitle goes below the title (narrow columns) instead of right-aligned.""" + out = [f''] + for i, line in enumerate(title_lines): + out.append(text(x + 24, y + 30 + i * 18, line.upper(), 12, 700, MUTED, extra='letter-spacing="1.8"')) + if subtitle and stacked: + out.append(text(x + 24, y + 30 + len(title_lines) * 18 + 4, subtitle, 12, 400, MUTED)) + elif subtitle: + out.append(text(x + w - 24, y + 30, subtitle, 12, 400, MUTED, "end")) + return "".join(out) + + +CHIP_H, CHIP_GAP = 22, 6 + + +def load_components(source: str, entries: list[grn.Entry]) -> dict[str, list[str]]: + """Module key -> component titles, from the Sphinx-Needs `comp` and `mod` needs of a needs.json.""" + if source.startswith("http"): + with urllib.request.urlopen(source, timeout=60) as response: + data = json.load(response) + else: + data = json.loads(Path(source).read_text()) + versions = data["versions"] + needs = versions.get(data.get("current_version", ""), next(iter(versions.values())))["needs"] + found: dict[str, dict[str, str]] = {e.key: {} for e in entries} + + def owner(need: dict) -> grn.Entry | None: + docname = need.get("docname") or "" + return next((e for e in entries if docname.startswith(f"modules/{e.key}/")), None) + + for need in needs.values(): + if need["type"] == "comp" and (e := owner(need)): + found[e.key][need["id"]] = need["title"] + elif need["type"] == "mod": + # module descriptions can also live in score_platform, so fall back to the title + e = owner(need) or next((e for e in entries if e.name == need["title"]), None) + for ref in need.get("includes", []) if e else []: + if comp := needs.get(ref.split("[")[0]): + found[e.key][comp["id"]] = comp["title"] + return {key: sorted(comps.values(), key=lambda t: (t.lower(), t)) for key, comps in found.items()} + + +def chip_lines(labels: list[str], available: float) -> list[list[tuple[str, float]]]: + lines: list[list[tuple[str, float]]] = [] + line: list[tuple[str, float]] = [] + used = 0.0 + for label in labels: + label = fit(label, 11, 500, available - 16)[0] + width = text_width(label, 11, 500) + 16 + if line and used + CHIP_GAP + width > available: + lines.append(line) + line, used = [], 0.0 + used += (CHIP_GAP if line else 0) + width + line.append((label, width)) + return lines + [line] if line else lines + + +def component_layout(entry: grn.Entry, w: float, is_new: bool, note: str, comps: list[str]): + """Name lines, name size, y offset of the chips and the minimum tile height.""" + lines, size = wrap(entry.name, 16, 700, w - 34 - (52 if is_new else 0)) + chips_y = 30 + (len(lines) - 1) * (size + 5) + 22 + (14 if note else 0) + 14 + n = len(chip_lines(comps, w - 34)) + block = n * CHIP_H + (n - 1) * CHIP_GAP if n else 20 + return lines, size, chips_y, chips_y + block + 16 + 28 + + +def component_tile(x, y, w, h, entry: grn.Entry, is_new: bool, note: str, comps: list[str]): + accent = RELEASED if entry.tag else UNRELEASED + out = card_base(x, y, w, h, accent) + lines, size, chips_y, _ = component_layout(entry, w, is_new, note, comps) + for i, line in enumerate(lines): + out.append(text(x + 20, y + 30 + i * (size + 5), line, size, 700, NAVY)) + vy = y + 30 + (len(lines) - 1) * (size + 5) + 22 + out.append(text(x + 20, vy, entry.tag or entry.pin[:7], 13, 700, INK if entry.tag else accent, family=MONO)) + if note: + out.append(text(x + 20, vy + 14, note, 10, 400, FAINT, family=MONO)) + cy = y + chips_y + for line in chip_lines(comps, w - 34): + cx = x + 20 + for label, cw in line: + out.append( + f'' + ) + out.append(text(cx + cw / 2, cy + 15, label, 11, 500, INK, "middle")) + cx += cw + CHIP_GAP + cy += CHIP_H + CHIP_GAP + if not comps: + out.append(text(x + 20, cy + 14, "no components documented", 11, 400, FAINT, extra='font-style="italic"')) + out.append( + f'' + ) + repo = entry.repo_url.rstrip("/").rsplit("/", 1)[-1] + out.append(text(x + 20, y + h - 11, fit(repo, 10, 400, w - 34)[0], 10, 400, FAINT, family=MONO)) + if is_new: + out.append(pill(x + w - 10, y + 10, "NEW", NEW)) + return "\n".join(out) + + +def component_rows(sw: list[grn.Entry], components: dict[str, list[str]], inner_w: float, gap: float, history, origin): + """Justified rows of up to 4 column units; modules with many components span 2 or 4 units.""" + rows: list[list[tuple[grn.Entry, int]]] = [[]] + for e in sw: + n = len(components.get(e.key, [])) + units = 4 if n > 8 else 2 if n > 3 else 1 + if sum(u for _, u in rows[-1]) + units > 4: + rows.append([]) + rows[-1].append((e, units)) + layout = [] + for row in rows: + total = sum(u for _, u in row) + unit_w = (inner_w - gap * (len(row) - 1)) / total + tiles, height = [], 0.0 + for e, units in row: + is_new, previous = history(e) + note = f"was {previous}" if previous else ("" if is_new else f"unchanged since {origin}") + w = unit_w * units + comps = components.get(e.key, []) + height = max(height, component_layout(e, w, is_new, note, comps)[3]) + tiles.append((e, w, is_new, note, comps)) + layout.append((tiles, height)) + return layout + + +def render( + release: str, + origin: str, + date: str, + final: bool, + groups: dict[str, list[grn.Entry]], + origin_groups: dict[str, list[grn.Entry]], + ref: str | None, + ref_label: str, + components: dict[str, list[str]] | None, +) -> str: + old = {grn.KEY_ALIASES.get(e.key, e.key): e for es in origin_groups.values() for e in es} + + def history(e: grn.Entry) -> tuple[bool, str | None]: + o = old.get(grn.KEY_ALIASES.get(e.key, e.key)) + if o is None: + return True, None + return False, (o.tag or o.pin[:7]) if o.pin != e.pin else None + + sw = [e for e in groups["target_sw"] if e.key != grn.PLATFORM_MODULE] + infra = [e for e in groups["target_sw"] if e.key == grn.PLATFORM_MODULE] + groups["tooling"] + + images = [ + (IMAGE_LABELS.get(directory, (name, directory)), "(Experimental)" in name) + for name, directory, _, _ in grn.IMAGES + if grn._exists(ref, f"images/{directory}") + ] + new_count = sum(history(e)[0] for e in sw + infra) + + parts: list[str] = [] + + # Header with key figures + short = ".".join(release.removeprefix("v").split(".")[:2]) + parts.append(f'') + title = f"S-CORE Platform v{short}" + parts.append(text(M, 54, title, 32, 800, "#ffffff")) + parts.append(text(M, 86, "Reference integration architecture", 16, 400, HEADER_TEXT)) + state, color = ("FINAL", RELEASED) if final else ("DRAFT", UNRELEASED) + parts.append(pill(M + text_width(title, 32, 800) + 20 + 12 + 7 * len(state), 30, state, color, 24)) + figures = [ + (str(len(sw)), "software modules"), + (str(len(infra)), "infrastructure modules"), + (str(len(images)), "target images"), + ] + if components is not None: + figures.insert(1, (str(sum(map(len, components.values()))), "components")) + x = W - M + for value, label in reversed(figures): + w = max(text_width(label, 11) + 8, 70) + parts.append(text(x, 62, value, 28, 800, "#ffffff", "end")) + parts.append(text(x, 82, label, 11, 400, HEADER_TEXT, "end")) + x -= w + 26 + when = f"released {date}" if final else f"planned {date}" + parts.append(text(M, 114, f"{release} \u00b7 {when} \u00b7 compared to {origin}", 12, 400, HEADER_FAINT)) + top = 156.0 + side_x, side_w = float(M), 300.0 + right_x = side_x + side_w + 20 + right_w = W - M - right_x + + # Body height: the taller of the infrastructure sidebar and the right column (with minimum tile height) + row_h, row_gap, rows_top = 38, 8, 92 + side_h = rows_top + len(infra) * row_h + (len(infra) - 1) * row_gap + 24 + cols, gap, min_tile_h = 4, 16, 120 + rows = (len(sw) + cols - 1) // cols + images_h = 128 + right_min = 56 + rows * min_tile_h + (rows - 1) * gap + 24 + 20 + images_h + if components is not None: + comp_rows = component_rows(sw, components, right_w - 48, gap, history, origin) + right_min = 56 + sum(h for _, h in comp_rows) + (len(comp_rows) - 1) * gap + 24 + 20 + images_h + body_h = max(side_h, right_min) + + # Left sidebar along the full body: platform, infrastructure & tooling + parts.append( + section(side_x, top, side_w, body_h, ["Platform, infrastructure", "& tooling"], f"{len(infra)} modules", True) + ) + for i, e in enumerate(infra): + y = top + rows_top + i * (row_h + row_gap) + parts.append(row_card(side_x + 20, y, side_w - 40, row_h, e, history(e)[0])) + + # Integrated software modules, flat grid + modules_h = body_h - 20 - images_h + parts.append(section(right_x, top, right_w, modules_h, ["Integrated software modules"], f"{len(sw)} modules")) + tile_w = (right_w - 48 - gap * (cols - 1)) / cols + tile_h = (modules_h - 56 - 24 - gap * (rows - 1)) / rows + if components is not None: + extra = (body_h - right_min) / len(comp_rows) + y = top + 56 + for tiles, h in comp_rows: + x = right_x + 24 + for e, w, is_new, note, comps in tiles: + parts.append(component_tile(x, y, w, h + extra, e, is_new, note, comps)) + x += w + gap + y += h + extra + gap + sw = [] + for i, e in enumerate(sw): + is_new, previous = history(e) + note = f"was {previous}" if previous else ("" if is_new else f"unchanged since {origin}") + x = right_x + 24 + (i % cols) * (tile_w + gap) + y = top + 56 + (i // cols) * (tile_h + gap) + parts.append(tile(x, y, tile_w, tile_h, e, is_new, note)) + + # Target images below the modules + y = top + body_h - images_h + parts.append(section(right_x, y, right_w, images_h, ["Target images"], "dashed: experimental")) + iw = (right_w - 48 - 14 * (len(images) - 1)) / max(len(images), 1) + for i, ((os_name, arch), experimental) in enumerate(images): + x = right_x + 24 + i * (iw + 14) + dash = ' stroke-dasharray="5 4"' if experimental else "" + parts.append( + f'' + ) + label, size = fit(os_name, 14, 700, iw - 16) + parts.append(text(x + iw / 2, y + 73, label, size, 700, NAVY, "middle")) + parts.append(text(x + iw / 2, y + 92, arch, 11, 400, MUTED, "middle", family=MONO)) + y = top + body_h + 20 + + # Legend + x = float(M) + for color, label in ((RELEASED, "released (tag)"), (UNRELEASED, "unreleased (commit pin)")): + parts.append(f'') + parts.append(text(x + 22, y + 14, label, 12, 400, MUTED)) + x += 40 + text_width(label, 12) + parts.append(pill(x + 40, y, "NEW", NEW)) + parts.append(text(x + 48, y + 14, f"new since {origin} ({new_count})", 12, 400, MUTED)) + x += 48 + text_width(f"new since {origin} ({new_count})", 12) + 24 + parts.append(text(x, y + 14, f"was \u2026 = version in {origin}", 12, 400, MUTED, family=FONT)) + parts.append( + text(W - M, y + 14, f"generated from known_good.json @ {ref_label}", 11, 400, FAINT, "end", family=MONO) + ) + y += 36 + + return ( + f'' + f'' + "\n".join(parts) + "" + ) diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 608e898b7d9..5520d761e30 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -28,6 +28,10 @@ The release date is the due date of the milestone ``v.`` in the eclipse-score/score repository. + +The skeleton embeds an architecture diagram that is rendered from scratch on every +run (``architecture_diagram.py``) to ``docs/s_core_v_1/releases/_assets/architecture_v_.svg``. +Its components come from ``--needs`` (default: needs.json of the published main docs). """ from __future__ import annotations @@ -58,6 +62,9 @@ # Module verification reports are linked from the current docs tree (same convention as the platform report). VERIFICATION_INDEX = "docs/verification_report/modules/index.rst" EMPTY_CELL = "---" +# Architecture diagram of the note; components come from the Sphinx-Needs of the published docs. +DIAGRAM_DIR = "docs/s_core_v_1/releases/_assets" +NEEDS_URL = f"https://eclipse-score.github.io/{RELEASE_REPO}/main/needs.json" # Old module names in earlier known_good.json versions -> current name used in the docs tree. KEY_ALIASES = {"score_lifecycle_health": "score_lifecycle", "score_process": "score_process_description"} @@ -380,6 +387,10 @@ def status_banner(release: str, date: str, status: str) -> str: """ +def diagram_name(release: str) -> str: + return "architecture_v" + "_".join(release.removeprefix("v").split(".")[:2]) + ".svg" + + def render(release: str, origin: str, date: str, status: str, groups: dict[str, list[Entry]], ref: str | None) -> str: short = ".".join(release.removeprefix("v").split(".")[:2]) doc_id = "v" + short.replace(".", "") @@ -425,6 +436,12 @@ def render(release: str, origin: str, date: str, status: str, groups: dict[str, included in the software platform release version {release} as compared to the previous platform release ({origin}). +.. figure:: /{DIAGRAM_DIR.removeprefix("docs/")}/{diagram_name(release)} + :width: 100% + :alt: S-CORE v{short} reference integration architecture + + Modules, components and images of the reference integration {release}. + """, _heading("Disclaimer", "-"), """ @@ -495,6 +512,33 @@ def render_changes(origin: dict[str, list[Entry]], current: dict[str, list[Entry return "\n".join(lines) +def write_diagram(args: argparse.Namespace, date: str, groups: dict[str, list[Entry]], ref: str | None) -> None: + import architecture_diagram + + software = [e for e in groups["target_sw"] if e.key != PLATFORM_MODULE] + try: + components = architecture_diagram.load_components(args.needs, software) + except (OSError, ValueError, KeyError) as error: + print(f"warning: no components in the diagram, cannot read {args.needs}: {error}", file=sys.stderr) + components = None + label = _git("rev-parse", "--short", f"{ref or 'HEAD'}^{{commit}}").strip() + svg = architecture_diagram.render( + args.release, + args.origin, + date, + args.final, + groups, + collect(load_known_good(args.origin)), + ref, + label, + components, + ) + path = REPO_ROOT / DIAGRAM_DIR / diagram_name(args.release) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(svg + "\n") + print(f"wrote {path.relative_to(REPO_ROOT)}", file=sys.stderr) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--release", required=True, help="Release tag, e.g. v0.10.0") @@ -511,6 +555,11 @@ def main() -> None: parser.add_argument("--final", action="store_true", help="Final release note (default: draft)") parser.add_argument("--changes", action="store_true", help="Print module version changes instead of the skeleton") parser.add_argument("--output", type=Path, help="Write to file instead of stdout") + parser.add_argument( + "--needs", + default=NEEDS_URL, + help="needs.json (path or URL) with the components for the architecture diagram (default: %(default)s)", + ) args = parser.parse_args() ref = args.known_good_ref or (args.release if _tag_exists(args.release) else None) @@ -522,6 +571,7 @@ def main() -> None: date = args.date or milestone_date(args.release) status = "valid" if args.final else "draft" text = render(args.release, args.origin, date, status, groups, ref) + write_diagram(args, date, groups, ref) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) From 5333631e25bc0449e75fe354702359fbaeeed4f6 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Wed, 28 Oct 2026 10:43:10 +0000 Subject: [PATCH 12/13] Release note: take the diagram components from the checkout's needs.json The generator builds //:needs_json_file with Bazel and reads bazel-bin/needs.json, so the components match the module pins of the checked-out release branch instead of the published main docs. A failing build stops the run; --needs only reproduces a diagram from a kept file. Removes the unused diagram path without components. --- .github/agents/release-note.agent.md | 5 +- .github/skills/release-note/SKILL.md | 31 +++++--- scripts/release_note/architecture_diagram.py | 76 +++++-------------- scripts/release_note/generate_release_note.py | 23 +++--- 4 files changed, 53 insertions(+), 82 deletions(-) diff --git a/.github/agents/release-note.agent.md b/.github/agents/release-note.agent.md index 7d54cb0155c..9a88edcc284 100644 --- a/.github/agents/release-note.agent.md +++ b/.github/agents/release-note.agent.md @@ -41,8 +41,9 @@ Read it first and follow it exactly. 2. Prepare: work on the release candidate branch of the release (ask for it if the current branch is `main`); `git pull` and `git fetch --tags`. Do not pass `--known-good-ref`; both scripts read the checked-out branch. -3. Run the generator to create the skeleton (this also renders the - architecture diagram) and the `--changes` table. +3. Run the generator to create the skeleton (this also builds the checkout's + `needs.json` with Bazel and renders the architecture diagram) and the + `--changes` table. Do not pass `--needs`. 4. Run `collect_changes.py` and classify into platform-level new features and breaking changes as described in the skill (use the impact hints). 5. Replace both `.. AI-FILL:` markers (New Features, Breaking Changes). diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index a9e68cc1ac1..a757c92ed22 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -116,21 +116,27 @@ Content: - Colour bar: green = released tag, orange = commit pin; NEW = not in the origin release. -Components come from the Sphinx-Needs of a `needs.json` (`--needs`, default: -the published docs of `main`): every `comp` need whose source lies in +Components come from the Sphinx-Needs of the `needs.json` of the **checked-out +branch**: the generator runs `bazel build //:needs_json_file` (Bazel caches it +and rebuilds only when the docs inputs changed; the first run takes a few +minutes) and reads `bazel-bin/needs.json`. These are the module docs at the +pins of the checkout, so components, versions and images describe the same +state. It takes every `comp` need whose source lies in `modules//`, plus the `includes` of the module's `mod` need (a `mod` need outside the module docs, for example in `score_platform`, is matched by its title). Modules without such needs show "no components documented"; report -them, do not add components by hand. If the `needs.json` cannot be read the -generator warns and draws the tiles without components. +them, do not add components by hand. If the Bazel build fails, the generator +stops; fix the build instead of falling back to another `needs.json`. +`--needs ` skips the build and is only for reproducing a diagram from a +kept `needs.json`. The diagram is a pure function of its inputs: `known_good.json` at the release -ref and at the origin tag, `images/` at the release ref, the `needs.json`, the -milestone date and the state (`--final`). There is no randomness, no timestamp -and no layout state; the same inputs give a byte-identical SVG. To check, run -the generator twice to a scratch output and `cmp` the SVG. The default -`needs.json` is the published `main` docs, which change over time; pass -`--needs ` to reproduce a diagram with a fixed component list. +ref and at the origin tag, `images/` at the release ref, the `needs.json` of the +checkout, the milestone date and the state (`--final`). There is no randomness, +no timestamp and no layout state; the same inputs give a byte-identical SVG. To +check, run the generator twice to a scratch output and `cmp` the SVG. +Run it on the checked-out release branch without `--known-good-ref`; with +another ref the components would still come from the checkout. Colours follow the S-CORE docs theme (header `#2D1942`); change them only in `architecture_diagram.py`. @@ -151,8 +157,9 @@ asks for release, origin and state (`draft` or `final`) and starts the agent. default; the release branch must be pushed, because the collector compares commits through the GitHub API. Set `GITHUB_TOKEN` for the GitHub API calls (anonymous access is rate limited); never print it. -3. **Skeleton.** Run the generator (see above); it also writes the - architecture diagram (see "Architecture diagram"). Check the stderr warnings. +3. **Skeleton.** Run the generator (see above); it also builds the + `needs.json` of the checkout with Bazel and writes the architecture diagram + (see "Architecture diagram"). Check the stderr warnings. 4. **Evidence.** Run `collect_changes.py` with the same `--release` and `--origin`, plus the `--changes` table. Runtime is a few minutes. 5. **Narrative.** Replace the two `AI-FILL` markers (New Features, Breaking diff --git a/scripts/release_note/architecture_diagram.py b/scripts/release_note/architecture_diagram.py index 22fbd2f567a..62f15c32712 100644 --- a/scripts/release_note/architecture_diagram.py +++ b/scripts/release_note/architecture_diagram.py @@ -14,14 +14,13 @@ Rendered by generate_release_note.py together with the skeleton; never edited by hand. Modules and images come from known_good.json and the repository, components from the -Sphinx-Needs ``comp``/``mod`` needs of a needs.json. +Sphinx-Needs ``comp``/``mod`` needs of the needs.json of the docs build. """ from __future__ import annotations import json import re -import urllib.request from pathlib import Path from xml.sax.saxutils import escape @@ -100,26 +99,6 @@ def card_base(x, y, w, h, accent): ] -def tile(x, y, w, h, entry: grn.Entry, is_new: bool, note: str): - accent = RELEASED if entry.tag else UNRELEASED - out = card_base(x, y, w, h, accent) - lines, size = wrap(entry.name, 16, 700, w - 34 - (52 if is_new else 0)) - for i, line in enumerate(lines): - out.append(text(x + 20, y + 30 + i * (size + 5), line, size, 700, NAVY)) - version = entry.tag or entry.pin[:7] - out.append(text(x + 20, y + h - 56, version, 18, 700, INK if entry.tag else accent, family=MONO)) - if note: - out.append(text(x + 20, y + h - 38, note, 10, 400, FAINT, family=MONO)) - out.append( - f'' - ) - repo = entry.repo_url.rstrip("/").rsplit("/", 1)[-1] - out.append(text(x + 20, y + h - 11, fit(repo, 10, 400, w - 34)[0], 10, 400, FAINT, family=MONO)) - if is_new: - out.append(pill(x + w - 10, y + 10, "NEW", NEW)) - return "\n".join(out) - - def row_card(x, y, w, h, entry: grn.Entry, is_new: bool): accent = RELEASED if entry.tag else UNRELEASED out = card_base(x, y, w, h, accent) @@ -148,13 +127,9 @@ def section(x, y, w, h, title_lines, subtitle="", stacked=False): CHIP_H, CHIP_GAP = 22, 6 -def load_components(source: str, entries: list[grn.Entry]) -> dict[str, list[str]]: +def load_components(path: Path, entries: list[grn.Entry]) -> dict[str, list[str]]: """Module key -> component titles, from the Sphinx-Needs `comp` and `mod` needs of a needs.json.""" - if source.startswith("http"): - with urllib.request.urlopen(source, timeout=60) as response: - data = json.load(response) - else: - data = json.loads(Path(source).read_text()) + data = json.loads(path.read_text()) versions = data["versions"] needs = versions.get(data.get("current_version", ""), next(iter(versions.values())))["needs"] found: dict[str, dict[str, str]] = {e.key: {} for e in entries} @@ -266,7 +241,7 @@ def render( origin_groups: dict[str, list[grn.Entry]], ref: str | None, ref_label: str, - components: dict[str, list[str]] | None, + components: dict[str, list[str]], ) -> str: old = {grn.KEY_ALIASES.get(e.key, e.key): e for es in origin_groups.values() for e in es} @@ -298,11 +273,10 @@ def history(e: grn.Entry) -> tuple[bool, str | None]: parts.append(pill(M + text_width(title, 32, 800) + 20 + 12 + 7 * len(state), 30, state, color, 24)) figures = [ (str(len(sw)), "software modules"), + (str(sum(map(len, components.values()))), "components"), (str(len(infra)), "infrastructure modules"), (str(len(images)), "target images"), ] - if components is not None: - figures.insert(1, (str(sum(map(len, components.values()))), "components")) x = W - M for value, label in reversed(figures): w = max(text_width(label, 11) + 8, 70) @@ -316,16 +290,12 @@ def history(e: grn.Entry) -> tuple[bool, str | None]: right_x = side_x + side_w + 20 right_w = W - M - right_x - # Body height: the taller of the infrastructure sidebar and the right column (with minimum tile height) + # Body height: the taller of the infrastructure sidebar and the right column row_h, row_gap, rows_top = 38, 8, 92 side_h = rows_top + len(infra) * row_h + (len(infra) - 1) * row_gap + 24 - cols, gap, min_tile_h = 4, 16, 120 - rows = (len(sw) + cols - 1) // cols - images_h = 128 - right_min = 56 + rows * min_tile_h + (rows - 1) * gap + 24 + 20 + images_h - if components is not None: - comp_rows = component_rows(sw, components, right_w - 48, gap, history, origin) - right_min = 56 + sum(h for _, h in comp_rows) + (len(comp_rows) - 1) * gap + 24 + 20 + images_h + gap, images_h = 16, 128 + comp_rows = component_rows(sw, components, right_w - 48, gap, history, origin) + right_min = 56 + sum(h for _, h in comp_rows) + (len(comp_rows) - 1) * gap + 24 + 20 + images_h body_h = max(side_h, right_min) # Left sidebar along the full body: platform, infrastructure & tooling @@ -336,27 +306,17 @@ def history(e: grn.Entry) -> tuple[bool, str | None]: y = top + rows_top + i * (row_h + row_gap) parts.append(row_card(side_x + 20, y, side_w - 40, row_h, e, history(e)[0])) - # Integrated software modules, flat grid + # Integrated software modules: justified rows of tiles with their components modules_h = body_h - 20 - images_h parts.append(section(right_x, top, right_w, modules_h, ["Integrated software modules"], f"{len(sw)} modules")) - tile_w = (right_w - 48 - gap * (cols - 1)) / cols - tile_h = (modules_h - 56 - 24 - gap * (rows - 1)) / rows - if components is not None: - extra = (body_h - right_min) / len(comp_rows) - y = top + 56 - for tiles, h in comp_rows: - x = right_x + 24 - for e, w, is_new, note, comps in tiles: - parts.append(component_tile(x, y, w, h + extra, e, is_new, note, comps)) - x += w + gap - y += h + extra + gap - sw = [] - for i, e in enumerate(sw): - is_new, previous = history(e) - note = f"was {previous}" if previous else ("" if is_new else f"unchanged since {origin}") - x = right_x + 24 + (i % cols) * (tile_w + gap) - y = top + 56 + (i // cols) * (tile_h + gap) - parts.append(tile(x, y, tile_w, tile_h, e, is_new, note)) + extra = (body_h - right_min) / len(comp_rows) + y = top + 56 + for tiles, h in comp_rows: + x = right_x + 24 + for e, w, is_new, note, comps in tiles: + parts.append(component_tile(x, y, w, h + extra, e, is_new, note, comps)) + x += w + gap + y += h + extra + gap # Target images below the modules y = top + body_h - images_h diff --git a/scripts/release_note/generate_release_note.py b/scripts/release_note/generate_release_note.py index 5520d761e30..b8341d5035f 100644 --- a/scripts/release_note/generate_release_note.py +++ b/scripts/release_note/generate_release_note.py @@ -31,7 +31,7 @@ The skeleton embeds an architecture diagram that is rendered from scratch on every run (``architecture_diagram.py``) to ``docs/s_core_v_1/releases/_assets/architecture_v_.svg``. -Its components come from ``--needs`` (default: needs.json of the published main docs). +Its components come from the needs.json of the checkout, built with ``bazel build //:needs_json_file``. """ from __future__ import annotations @@ -62,9 +62,9 @@ # Module verification reports are linked from the current docs tree (same convention as the platform report). VERIFICATION_INDEX = "docs/verification_report/modules/index.rst" EMPTY_CELL = "---" -# Architecture diagram of the note; components come from the Sphinx-Needs of the published docs. +# Architecture diagram of the note; components come from the Sphinx-Needs of the checkout's docs. DIAGRAM_DIR = "docs/s_core_v_1/releases/_assets" -NEEDS_URL = f"https://eclipse-score.github.io/{RELEASE_REPO}/main/needs.json" +NEEDS_TARGET = "//:needs_json_file" # Old module names in earlier known_good.json versions -> current name used in the docs tree. KEY_ALIASES = {"score_lifecycle_health": "score_lifecycle", "score_process": "score_process_description"} @@ -512,15 +512,18 @@ def render_changes(origin: dict[str, list[Entry]], current: dict[str, list[Entry return "\n".join(lines) +def build_needs() -> Path: + """needs.json of the checked-out docs; Bazel rebuilds it only when the docs inputs changed.""" + print(f"building {NEEDS_TARGET} ...", file=sys.stderr) + subprocess.run(["bazel", "build", NEEDS_TARGET], cwd=REPO_ROOT, check=True, stdout=sys.stderr) + return REPO_ROOT / "bazel-bin" / "needs.json" + + def write_diagram(args: argparse.Namespace, date: str, groups: dict[str, list[Entry]], ref: str | None) -> None: import architecture_diagram software = [e for e in groups["target_sw"] if e.key != PLATFORM_MODULE] - try: - components = architecture_diagram.load_components(args.needs, software) - except (OSError, ValueError, KeyError) as error: - print(f"warning: no components in the diagram, cannot read {args.needs}: {error}", file=sys.stderr) - components = None + components = architecture_diagram.load_components(args.needs or build_needs(), software) label = _git("rev-parse", "--short", f"{ref or 'HEAD'}^{{commit}}").strip() svg = architecture_diagram.render( args.release, @@ -557,8 +560,8 @@ def main() -> None: parser.add_argument("--output", type=Path, help="Write to file instead of stdout") parser.add_argument( "--needs", - default=NEEDS_URL, - help="needs.json (path or URL) with the components for the architecture diagram (default: %(default)s)", + type=Path, + help=f"needs.json for the components of the architecture diagram (default: bazel build {NEEDS_TARGET})", ) args = parser.parse_args() From 7f1fbf70b86d6aee8a59921642f86521104d231b Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Wed, 28 Oct 2026 10:46:16 +0000 Subject: [PATCH 13/13] Release note: list commits without PR of the release branch as evidence The evidence of this repository covers the range origin tag -> checked-out release branch, but only squash-merged PRs ("(#N)") were listed, so commits pushed directly to the release candidate branch were dropped. collect_changes.py now lists them with their changed files (commits that only touch the release notes are skipped). The skill classifies them like PRs and, on refresh, adds items for new evidence while keeping the reviewed narrative. --- .github/skills/release-note/SKILL.md | 21 ++++++++++++++++----- scripts/release_note/collect_changes.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/skills/release-note/SKILL.md b/.github/skills/release-note/SKILL.md index a757c92ed22..61f45c2c4e1 100644 --- a/.github/skills/release-note/SKILL.md +++ b/.github/skills/release-note/SKILL.md @@ -173,7 +173,9 @@ write to another path. To refresh the tables of an existing draft (for example after pin bumps, or to make it final with `--final`), generate to another path and replace everything outside "Changes to the Platform"; keep the reviewed narrative. The diagram SVG is always overwritten by the run, so it -matches the refreshed tables. +matches the refreshed tables. Then run `collect_changes.py` again and add items +for evidence that is not covered yet (new PRs or direct commits on the release +branch); keep the reviewed items unless their evidence changed. Re-running with the same inputs gives the same tables and chapter structure. The narrative may be worded differently, but it has to name the same items as @@ -206,8 +208,13 @@ Evidence: python3 scripts/release_note/collect_changes.py --release vX.Y.0 --origin vX.W.0 --output changes.md ``` -The output has one section for the merged PRs and closed issues of this -repository, one for the files of this repository that were removed or renamed +The evidence of this repository covers the commit range from the origin tag to +the checked-out release branch (not `main`). The output has one section for +the merged PRs and closed issues of this repository, one for the **commits +without PR** in that range (commits pushed directly to the release candidate +branch, with their changed files; commits that only touch +`docs/s_core_v_1/releases/` are skipped), one for the files of this repository +that were removed or renamed (per directory, without `docs/` and `patches/`), and one section per changed module with the **breaking** and **new-feature** parts of the module's own GitHub release notes. For every @@ -238,6 +245,9 @@ Classification: - Anything else is omitted. When unsure, omit it and name it in the final report. - PR labels like `breaking` or `feature` are not used consistently; decide from title, description and the module's release note. +- Commits without PR are classified like PRs; read the commit (`git show`) + because the subject is often short. Pure pin bumps are already covered by the + `--changes` table and the module sections. Rules: @@ -245,7 +255,8 @@ Rules: Read the PR description before describing a PR; the title alone is not enough (a PR may replace something instead of extending it). - One `~~~` subsection per item with a short noun-phrase title, written for - integrators; link to the PR, issue or the module's release note as RST links + integrators; link to the PR, issue, commit (for commits without PR) or the + module's release note as RST links (`` `text `__ ``). Name new scripts, targets or settings that integrators use. - Order of the items: module set, integration and build, images and platforms, @@ -275,7 +286,7 @@ Known limits of the evidence: `docs/s_core_v_1/releases/_assets/architecture_v_.svg` was written by this run (it is committed together with the note). - The new note is part of the `toctree` in `releases.rst`. -- Every `~~~` item and every bullet names a PR, issue or release note link. +- Every `~~~` item and every bullet names a PR, issue, commit or release note link. - Every new module of the `--changes` table and every removed mechanism of this repository is either in the note or named in the report. - No stderr warnings of the generator; `(unreleased)` rows are named in the report. diff --git a/scripts/release_note/collect_changes.py b/scripts/release_note/collect_changes.py index f40c32b93a8..bb577817cb8 100644 --- a/scripts/release_note/collect_changes.py +++ b/scripts/release_note/collect_changes.py @@ -46,6 +46,7 @@ BREAKING_TITLE = re.compile(r"^\w+(\([^)]*\))?!:|\bbreaking\b", re.I) FEATURE_TITLE = re.compile(r"^(feat|feature)(\([^)]*\))?:|^add(ed)?\b|^introduce|^implement|^support", re.I) CLOSES = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.I) +RELEASE_NOTES_DIR = "docs/s_core_v_1/releases/" def api(path: str, params: dict[str, Any] | None = None) -> Any: @@ -307,6 +308,23 @@ def render_release_notes(name: str, slug: str, old: grn.Entry, new: grn.Entry) - return "\n".join(out) +def render_direct_commits(base: str, head: str) -> str: + """Commits of this repository without a PR, e.g. pushed directly to the release candidate branch.""" + log = grn._git("log", "--no-merges", "--reverse", "--format=%H%x09%s", f"{base}..{head}") + out = [] + for sha, subject in (line.split("\t", 1) for line in log.splitlines()): + if re.search(r"\(#\d+\)\s*$", subject): + continue # squash-merged PR, listed above + files = grn._git("diff-tree", "--no-commit-id", "--name-only", "-r", sha).split() + if all(f.startswith(RELEASE_NOTES_DIR) for f in files): + continue # the release note itself + shown = ", ".join(files[:10]) + (f" (+{len(files) - 10} more)" if len(files) > 10 else "") + url = f"https://github.com/eclipse-score/{grn.RELEASE_REPO}/commit/{sha}" + out.append(f"- {sha[:10]} {subject} ({url})\n - files: {shown}") + heading = f"## Reference integration: commits without PR {base} -> {head[:10]} ({len(out)})" + return "\n".join([heading, "", *out] if out else [heading, "", "None."]) + "\n" + + def render_removed_files(base: str, head: str) -> str: """Files of this repository deleted or renamed between the two refs, per directory (breaking candidates).""" result = subprocess.run( @@ -361,6 +379,7 @@ def main() -> None: # One commit for all evidence of this repository, the same one known_good.json is read from. head = grn._git("rev-parse", f"{ref}^{{commit}}").strip() sections.append(render_module("Reference integration", ref_slug, args.origin, head, own_repo=True)) + sections.append(render_direct_commits(args.origin, head)) sections.append(render_removed_files(args.origin, head)) renamed_from = {new: old for old, new in grn.KEY_ALIASES.items()}