A standalone tool that generates Open 3D Engine (O3DE) release notes by extracting merged pull requests from GitHub, categorizing them by SIG (Special Interest Group), and rendering markdown in the established release notes format.
Designed to be run incrementally throughout the pre-release cycle so the release team can track progress as PRs land.
Currently in use for the O3DE 26.10.0 cycle. See RELEASE_RUNBOOK.md for the step-by-step procedure.
- Python 3.10+
- GitHub CLI (
gh) installed and authenticated (gh auth login) - Local clone(s) of O3DE repositories (read-only reference)
- (Optional) An LLM for automated narrative summary generation: Ollama (local, open-source) or Claude CLI (cloud)
# Generate release notes for 26.10.0 (everything since the 26.05.0 report)
python release_notes.py generate \
--from-ref 2605.0 \
--to-ref origin/development \
--default-repo-path /path/to/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md 26100_release_notes.md \
--release-version 26.10.0--exclude-json is not optional for a major release. A release tag sits on the
main line and shares only an ancient merge-base with development, so without
it this window reaches back to 2025-07-29 and re-publishes 188 PRs that already
shipped in 26.05.0. The previous report is committed at
reports/26050_release_data.json, so the command above works in a fresh clone.
See Excluding the Previous Release.
Switch --to-ref to origin/stabilization/26100 once that branch is cut. If
point releases ship on the 2605 line, use the latest of them (2605.1,
2605.2, …) as --from-ref.
o3de-release-notes-generator/
├── README.md # This file
├── ARCHITECTURE.md # Architecture, security model, data flow
├── CHANGELOG.md # Version history (Keep a Changelog format)
├── CONTRIBUTING.md # Dev workflow, dual-license, SHA-pin policy
├── SECURITY.md # Vulnerability disclosure
├── AGENTS.md # AI agent instructions for this repo
├── RELEASE_RUNBOOK.md # Step-by-step procedure for a release cycle
├── release_notes.py # Main script (zero external dependencies)
├── generate_sbom.py # CycloneDX 1.5 SBOM generator
├── sbom.cdx.json # Generated SBOM (auto-updated via CI)
├── pyproject.toml # pytest / ruff / mypy config
├── Makefile # test / sbom / lint / typecheck targets
├── tests/
│ └── test_release_notes.py # Unit tests
├── reports/ # Per-release reports (committed)
│ ├── 26050_release_data.json # 26.05.0 report; exclusion source for 26.10.0
│ ├── 26100_release_data.json # 26.10.0 working draft
│ ├── 26100_release_notes.md # 26.10.0 rendered draft
│ └── hints/ # Reusable --summary-hint files
├── .github/
│ └── workflows/
│ ├── sbom.yml # Auto-regenerates SBOM on push
│ └── test.yml # Runs pytest on push & PR
├── LICENSE.txt # Dual-license overview
├── LICENSE_APACHE2.TXT # Apache License 2.0
├── LICENSE_MIT.TXT # MIT License
└── .gitignore
The tool has three subcommands: fetch, render, and generate.
python release_notes.py --version prints the tool version and exits. It is
worth quoting in bug reports, since metadata.tool_version in the JSON records
which build produced a given report.
python release_notes.py fetch \
--from-ref <start-tag> \
--to-ref <end-branch> \
--default-repo-path <path-to-local-clone> \
--output-json <output.json> \
[--repos owner/repo ...] \
[--repo-path owner/repo=/path ...] \
[--repo-from-ref owner/repo=REF ...] \
[--repo-to-ref owner/repo=REF ...] \
[--reuse-existing] \
[--exclude-json prior_release.json ...] \
[--dry-run] \
[--no-pointrelease-audit] \
[--log-file PATH] \
[-v]| Flag | Required | Default | Description |
|---|---|---|---|
--from-ref |
Yes | - | Starting git reference (tag or commit) |
--to-ref |
Yes | - | Ending git reference (branch or tag) |
--default-repo-path |
No | . |
Default local clone path for repos without explicit mapping |
--repo-path |
No | - | Per-repo clone paths as owner/repo=/path/to/clone (repeatable) |
--repo-from-ref |
No | - | Per-repo override for --from-ref as owner/repo=REF (repeatable). Needed when a release tag exists in some repos but not others |
--repo-to-ref |
No | - | Per-repo override for --to-ref as owner/repo=REF (repeatable) |
--reuse-existing |
No | off | Reuse PR data already in --output-json instead of re-fetching. Only label-categorised PRs are reused; heuristic and uncategorised PRs are always re-fetched so a SIG label applied since the last run is picked up |
--exclude-json |
No | - | Prior release report JSON(s). PRs already reported there are dropped from the window and never fetched (repeatable). Required for a correct major-release window; see below |
--output-json |
Yes | - | Output JSON file path |
--repos |
No | o3de/o3de |
GitHub repos in owner/repo format (where PRs live) |
--dry-run |
No | off | Print which PRs would be fetched (from git log) without calling the GitHub API or writing files |
--no-pointrelease-audit |
No | off | Skip the point-release audit sidecar even when --from-ref looks like a point-release tag (MAJOR.PATCH with a non-zero patch) |
--log-file |
No | - | Append logs to this file in addition to stderr. The path is validated like any other output path; if it is unwritable the run continues with stderr-only logging rather than aborting |
-v, --verbose |
No | - | Verbose logging |
python release_notes.py render \
--input-json <input.json> \
--output-md <output.md> \
--release-version <version-string> \
[--include-uncategorized] \
[--include-release-machinery] \
[--include-duplicates] \
[--generate-summary] \
[--summary-cmd <command>] \
[--summary-hint <text>] \
[--summary-timeout <seconds>] \
[--log-file PATH]| Flag | Required | Default | Description |
|---|---|---|---|
--input-json |
Yes | - | Path to JSON from fetch |
--output-md |
Yes | - | Output markdown file path |
--release-version |
Yes | - | Release version string (e.g., 26.05.0) |
--include-uncategorized |
No | off | Show PRs that couldn't be categorized |
--include-release-machinery |
No | off | Include release-engineering PRs (version bumps, SBOM auto-updates, cherry-pick-to-pointrelease wrappers, etc.) in the rendered output. Off by default for major releases; turn on for point-release notes where machinery IS the content |
--include-duplicates |
No | off | Keep every PR sharing a title within a repo. Off by default: when two PRs in the same repo have the same title and the same changed-file list, one bullet is rendered and the collapsed PR numbers are logged at WARNING |
--generate-summary |
No | off | Generate a narrative summary using an LLM |
--summary-cmd |
No | ollama run --nowordwrap qwen2.5:14b |
Command to generate the summary |
--summary-hint |
No | - | Narrative guidance: inline text or @filepath to read from a file |
--summary-timeout |
No | 300 |
Timeout (seconds) for the summary command (range: 10–3600) |
--log-file |
No | - | Append logs to this file in addition to stderr. The path is validated like any other output path; if it is unwritable the run continues with stderr-only logging rather than aborting |
Combines fetch and render. Accepts all flags from both subcommands.
python release_notes.py generate \
--from-ref 2605.0 \
--to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md 26100_release_notes.md \
--release-version 26.10.0Re-run the same command. By default every PR in the range is re-fetched from
GitHub on each run; add --reuse-existing to serve label-categorised PRs from
the previous report instead (see below). Either way, manual edits in the JSON are
preserved: manual_override_sig and manual_override_description survive
re-runs.
# Week 1
python release_notes.py generate --from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md notes.md --release-version 26.10.0
# Week 2 (same command; re-fetches the full range, re-applies your overrides)
python release_notes.py generate --from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md notes.md --release-version 26.10.0A full range costs roughly one GraphQL request per 30 PRs, so a ~420-PR cycle is about 14 requests. Weekly re-runs are comfortably inside GitHub's rate limits.
--reuse-existing cuts that further by serving PRs from the previous report
instead of re-fetching them, but only PRs categorised by GitHub label.
A PR that fell to a heuristic, or failed to categorise, is exactly the one whose
sig/* label may have been applied since the last run, so those are always
re-fetched; caching them would freeze a wrong SIG for the rest of the cycle. In
the 26.10.0 draft that is 120 of 200 reusable. Derived fields (SIG, description,
flags, machinery) are recomputed for reused PRs, so a heuristic change still
reaches cached entries without a re-fetch.
Two batch failures are handled specifically rather than by splitting the batch
into one request per PR. A PR number GitHub cannot resolve is permanent (usually
an issue reference picked up from a commit subject, e.g. Fix thing (#18886) (#19254)): the batch drops it and retries once. A transient failure, such as a
secondary rate limit, backs off exponentially up to 3 attempts. Only an
unrecognised failure falls back to per-PR requests.
python release_notes.py generate \
--from-ref 2605.0 --to-ref origin/development \
--repos o3de/o3de o3de/o3de-extras \
--default-repo-path ~/PROJECTS/o3de \
--repo-path o3de/o3de-extras=~/PROJECTS/o3de-extras \
--repo-from-ref o3de/o3de-extras=2510.2 \
--exclude-json reports/26050_release_data.json \
--output-json release_data.json \
--output-md notes.md \
--release-version 26.10.0Each repo runs git log against its own local clone. The --default-repo-path is used for any repo without an explicit --repo-path mapping.
Not every repo is tagged on every release line. o3de/o3de carries 2605.0, but o3de/o3de-extras does not, so a single global --from-ref 2605.0 cannot resolve there. Use --repo-from-ref owner/repo=REF to give that repo its own starting point (--repo-to-ref does the same for the end of the range). A preflight check resolves every (repo, ref) pair before any work starts and fails with an actionable message rather than aborting part-way through a run.
python release_notes.py generate \
--from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md notes.md \
--release-version 26.10.0 \
--generate-summaryThis builds a structured prompt from the categorized PR data and pipes it via stdin to the summary command (default: ollama run --nowordwrap qwen2.5:14b). The generated narrative replaces the placeholder intro in the markdown output.
To use a different model or tool:
# Claude CLI (cloud, highest quality)
--generate-summary --summary-cmd "claude -p"
# Larger local model for machines with more VRAM
--generate-summary --summary-cmd "ollama run --nowordwrap qwen2.5:32b"
# Or any tool that reads a prompt from stdin and writes to stdout
--generate-summary --summary-cmd "my-llm-tool --flag"
# Bump the timeout for slower models / hardware
--generate-summary --summary-timeout 900The command must read the prompt from stdin and write its response to stdout.
Use --summary-hint to guide the LLM toward specific themes or tone:
python release_notes.py generate \
--from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md notes.md \
--release-version 26.10.0 \
--generate-summary \
--summary-hint "This is a major platform expansion release. Emphasize Wayland support, Mac ARM64, and Emscripten. Note that PhysX4 deprecation is a breaking change."The hint is injected into the LLM prompt as "additional guidance from the release manager" and shapes the narrative without overriding the structured PR data.
To load the hint from a file, prefix the path with @:
--summary-hint @release_briefing.txtThis is useful for longer guidance or when reusing the same narrative direction across incremental runs.
python release_notes.py fetch \
--from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.jsonpython release_notes.py generate \
--from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json 26100_release_data.json \
--output-md notes.md \
--release-version 26.10.0 \
--include-uncategorizedpython release_notes.py fetch \
--from-ref 2605.0 --to-ref origin/development \
--default-repo-path ~/PROJECTS/o3de \
--exclude-json reports/26050_release_data.json \
--output-json /tmp/unused.json \
--dry-runReads git log locally and prints the PR numbers that would be fetched, with the
exclusion applied so the preview matches what a real run would fetch. No GitHub API calls; no files written. Always do this first: it verifies refs and clone paths, and it is the cheapest way to confirm the PR count looks right before a long run.
When point releases have shipped between the previous major and the current cycle (e.g. 2605.0 → 2605.1 → 2605.2), pass the latest point-release tag as --from-ref:
python release_notes.py generate \
--from-ref 2605.2 \
--to-ref origin/stabilization/26100 \
--repos o3de/o3de o3de/o3de-extras \
--repo-path o3de/o3de=~/PROJECTS/o3de \
--repo-path o3de/o3de-extras=~/PROJECTS/o3de-extras \
--repo-from-ref o3de/o3de-extras=2510.2 \
--exclude-json reports/26050_release_data.json \
--output-json reports/26100_release_data.json \
--output-md reports/26100_release_notes.md \
--release-version 26.10.0Point-release tags are two-component (MAJOR.PATCH, e.g. 2605.2), where the
major token encodes year and month.
The tool auto-detects the point-release pattern and:
- Emits a one-line
INFOlog noting that the merge-base of2605.0and2605.2against--to-refis identical (point-release cherry-picks are correctly excluded; their bundled fixes are counted via the development-side merges instead). - Writes a point-release audit sidecar at
reports/26100_release_notes_pointrelease_audit.mdlisting every cherry-pick container PR found on the previous stabilization branch, with each bundled PR shown as ✓ (present in the rendered report) or ✗ (missing; investigate). Turns the manual "did we lose any fixes?" check into a one-glance checklist. Suppress with--no-pointrelease-audit. - Flags release-machinery PRs (version bumps, SBOM auto-updates, cherry-pick wrappers, "merging pointrelease into main" merges, etc.) with
release_machinery: truein the JSON and excludes them from the rendered output. Opt back in with--include-release-machinery; useful for point-release notes where the machinery PRs are the headline content.
A real run over the 26.10.0 window (o3de/o3de, 181 PRs after excluding the
26.05.0 report) renders something like:
# 26.10.0 Release Notes
The O3DE 26.10.0 release includes bug fixes, performance enhancements,
and new features across the engine.
<!-- TODO: Write a narrative summary of the release highlights -->
# Full list of changes
## SIG-Build
- Remove system cmake dependency from the Linux installer. [o3de#19704](https://github.com/o3de/o3de/pull/19704)
- Update vcpkg baseline for clang-19 builds. [o3de#19712](https://github.com/o3de/o3de/pull/19712)
- ...
## SIG-Graphics-Audio
- Fix shader compilation error in Atom on dx12. [o3de#19651](https://github.com/o3de/o3de/pull/19651)
- ...
## SIG-Platform
- Initial Wayland support for Linux. [o3de#19589](https://github.com/o3de/o3de/pull/19589)
- ...The <!-- TODO --> placeholder is replaced with a real narrative when --generate-summary is used. A complete sample run is checked in under reports/ (one full release; refresh manually as desired).
A release tag is not a usable window boundary on its own. O3DE's main line
is built from periodic "merge stabilization to main" commits, so a tag like
2605.0 shares only an ancient merge-base with development:
merge-base(2605.0, origin/development) = 57680ee42 (2025-07-29)
That is before the 26.05 cycle began, so 2605.0..origin/development spans two
release cycles. Measured against real clones:
| Window | PRs found | Already in the 26.05.0 notes | Genuinely new |
|---|---|---|---|
o3de/o3de 2605.0..development |
369 | 188 | 181 |
o3de/o3de-extras 2510.2..development |
50 | 30 | 20 |
The duplicates are the development-side merges of fixes that reached the previous release by cherry-pick into its stabilization branch. They are distinct commits with distinct SHAs, unreachable from the tag, so neither a different ref nor a date cutoff can separate them (the two sets interleave in time: the new PRs start 2025-09-05 while the already-shipped ones run to 2026-04-12).
Pass the previous release's report as an exclusion source:
--exclude-json reports/26050_release_data.jsonPRs already reported there are dropped before any GitHub call, which also cuts
the fetch cost. The sources used, and the per-repo counts excluded, are recorded
in metadata.excluded_prior_releases. Pointing --exclude-json at this run's
own --output-json is refused, since it would empty the report on the next run.
Every render (and the render half of generate) prints an explicit account of
what reached the report and what did not:
[INFO] o3de.release_notes: Reconciliation: 201 PR(s) in JSON, 190 rendered
[WARNING] o3de.release_notes: Excluded 11 PR(s) from the report: cherry-pick=7,
release_machinery=1, uncategorized=3. Re-run render with
--include-uncategorized / --include-release-machinery to inspect them.
The counts are mutually exclusive and sum to the total, so nothing can be dropped silently. Read this line on every run. A sudden jump in any excluded category means a heuristic has started over-matching; that is exactly how 57 real PRs went missing from the 26.05.0 notes undetected.
The same change can merge twice under two PR numbers, usually a resubmission or the same fix proposed against two branches. Both then land in the window and the notes print the change twice. The 26.10.0 draft had four such pairs.
A pair is collapsed only with positive evidence that it is one change: same repo, same title (whitespace- and case-insensitive), and the same set of changed files. Title alone is too weak over a couple hundred PRs, where a subject like "Fix build error" recurs on unrelated work, and deleting a real change is far worse than printing a bullet twice. A PR with no recorded file list is never collapsed, since absent evidence is not evidence of sameness.
Every collapse is named, not merely counted, so the call can be checked:
[WARNING] o3de.release_notes: Duplicate title in o3de/o3de: kept #19949,
collapsed #19957 ('fix(maestro): Fix operator precedence in
CCompoundSplineTrack::RemoveKey'). Re-run render with
--include-duplicates to keep all of them.
The survivor is the PR with a real SIG where the pair straddles categorized and
uncategorized, and otherwise the lower number. Collapsed PRs appear in the
reconciliation line as duplicate=N. Pass --include-duplicates to keep every
copy.
The point-release audit deliberately counts a collapsed duplicate as present: the fix did reach the reader, through its twin's bullet.
The intermediate JSON is the primary data format. It can be edited by humans or consumed by AI agents.
{
"metadata": {
"generated_at": "2026-08-03T10:00:00+00:00",
"from_ref": "2605.0",
"to_ref": "origin/development",
"repos": ["o3de/o3de", "o3de/o3de-extras"],
"repo_paths": {
"o3de/o3de": "/home/user/PROJECTS/o3de",
"o3de/o3de-extras": "/home/user/PROJECTS/o3de-extras"
},
"schema_version": 6,
"tool_version": "0.8.1-beta",
"pr_count": 201,
"categorization_summary": {
"label": 131,
"heuristic_title": 45,
"heuristic_files": 22,
"uncategorized": 3
},
"release_machinery_count": 1,
"merge_bases": {
"o3de/o3de": {
"sha": "57680ee42f18d5952e4d4fa5ab52750edefb878e",
"committer_date": "2025-07-29T11:12:47-07:00"
},
"o3de/o3de-extras": {
"sha": "3038e4ac7b566b8b0ab7360acc67d6280eb68eba",
"committer_date": "2025-09-08T14:48:13+02:00"
}
},
"effective_window": {
"start": "2025-07-29T11:12:47-07:00",
"end": "2026-08-03T10:00:00+00:00"
},
"reused_from_cache": {
"per_repo": {"o3de/o3de": 120},
"total": 120,
"policy": "label-categorised PRs only; all others re-fetched"
},
"file_list_truncated": {
"page_size": 100,
"count": 9,
"prs": ["o3de/o3de#19109", "o3de/o3de#19906"],
"categorized_from_partial_files": ["o3de/o3de#19906"]
},
"excluded_prior_releases": {
"sources": ["/home/user/.../reports/26050_release_data.json"],
"per_repo": {"o3de/o3de": 188, "o3de/o3de-extras": 30},
"total": 218
},
"repo_refs": {
"o3de/o3de": {"from_ref": "2605.0", "to_ref": "origin/development"},
"o3de/o3de-extras": {"from_ref": "2510.2", "to_ref": "origin/development"}
}
},
"pull_requests": [
{
"number": 19709,
"repo": "o3de/o3de",
"title": "Fix for choppy mouse movement in FlyCameraInputComponent",
"url": "https://github.com/o3de/o3de/pull/19709",
"author": "contributor",
"merged_at": "2026-04-20T17:14:14Z",
"labels": ["sig/content"],
"files": ["Gems/AtomLyIntegration/.../FlyCameraInputComponent.cpp"],
"files_truncated": false,
"sig_category": "sig/content",
"categorization_source": "label",
"description": "Fix for choppy mouse movement in FlyCameraInputComponent.",
"flags": [],
"release_machinery": false,
"manual_override_sig": null,
"manual_override_description": null
}
]
}| Field | Description |
|---|---|
sig_category |
Assigned SIG. Set automatically, or via manual_override_sig. |
categorization_source |
How the SIG was assigned: label, heuristic_title, heuristic_files, uncategorized, manual_override |
flags |
Auto-detected flags. Currently only cherry-pick (title evidence), which excludes the PR from rendered markdown. A legacy stabilization-sync value may appear in JSON written by ≤0.5.0-beta; it is ignored on render. |
release_machinery |
Auto-detected boolean for release-engineering PRs (version bumps, SBOM auto-updates, cherry-pick-to-pointrelease wrappers, engine.json/sbom.cdx.json/version.txt/.github/FUNDING.yml-only diffs). Excluded from rendered markdown and summary prompts by default; opt back in with --include-release-machinery. |
manual_override_sig |
Set this to reassign a PR to a different SIG. Preserved on re-runs. |
manual_override_description |
Set this to override the auto-generated description. Preserved on re-runs. |
metadata.merge_bases |
Per-repo {sha, committer_date} for the merge-base of from_ref and to_ref. Anchors the actual fork point. |
metadata.effective_window |
{start, end} window the diff covers. start is the earliest merge-base committer-date across repos; end is generated_at. |
metadata.release_machinery_count |
Number of PRs flagged release_machinery: true in this run. |
metadata.tool_version |
Version of release_notes.py that produced the file. Present from schema 4. |
files_truncated |
True when the PR hit GitHub's 100-file page cap, so files is partial. Matters only when categorization_source is heuristic_files. |
metadata.reused_from_cache |
Per-repo count of PRs served from the previous report rather than re-fetched, and the policy used. |
metadata.file_list_truncated |
Roll-up of the above: how many PRs were capped, which ones, and the subset whose SIG was decided by the file heuristic from a partial list. Verify those before publishing. |
metadata.excluded_prior_releases |
Which prior reports were used as exclusion sources and how many PRs each repo dropped because of them. |
metadata.repo_refs |
Per-repo {from_ref, to_ref}. Emitted only when --repo-from-ref / --repo-to-ref made a repo's range differ from the global one. |
PR numbers come from the local git log over <from-ref>..<to-ref>, matched two ways:
| Merge strategy | Commit subject | Pattern |
|---|---|---|
| Squash merge | Fix choppy mouse movement (#19709) |
\(#(\d+)\) |
| Merge commit | Merge pull request #19882 from o3de/branch |
^Merge pull request #(\d+) |
Both are required. O3DE development uses merge commits for a large minority of
PRs, whose constituent commits carry no PR reference at all; matching only the
squash form (and passing --no-merges) missed 19 PRs in the 26.05.0 → 26.10.0
window. The count found via merge commits is logged on each run.
PRs are categorized using three methods in priority order:
- GitHub labels - PRs with
sig/*labels (e.g.,sig/build,sig/graphics-audio) are categorized directly. Highest confidence. - Title keywords - PR titles are matched against keyword lists per SIG.
- File paths - Changed file paths are matched against directory-to-SIG mappings.
If none match, the PR is marked uncategorized for manual triage.
The categorization data lives as four data-driven structures at the top of release_notes.py:
| Constant | Purpose |
|---|---|
SIG_CANONICAL_ORDER |
Canonical SIG list. Defines section order in markdown output and acts as the deterministic tiebreaker when a PR has multiple SIG labels or its title matches keywords from multiple SIGs. |
SIG_DISPLAY_NAMES |
Map from sig/foo → SIG-Foo (the heading that appears in the rendered markdown). |
SIG_TITLE_KEYWORDS |
Per-SIG keyword list for the title-heuristic categorizer. |
SIG_FILE_PATH_PATTERNS |
Per-SIG file-path prefix list for the file-heuristic categorizer (longest-match-wins). |
To adjust an existing SIG's heuristics, edit SIG_TITLE_KEYWORDS and/or SIG_FILE_PATH_PATTERNS. To add a new SIG, you must update all four; otherwise the new SIG either won't render (missing display name) or won't be picked up at all (missing from canonical order).
Determinism note: When a PR has multiple SIG labels, or its title hits keywords in multiple SIGs, the SIG that comes earliest in
SIG_CANONICAL_ORDERwins. This guarantees the same PR is categorized the same way on every run, regardless of label order from the GitHub API or dict iteration order.
When --generate-summary is enabled, the tool builds a structured prompt from the categorized PR data and sends it to a configurable LLM command.
How it works:
- PRs are grouped by SIG with up to 15 titles per group (truncated for large sections)
- Cherry-picks, release-machinery PRs, and uncategorized PRs are excluded from the prompt
- If
--summary-hintis provided (inline text or@filepath), it's injected as "additional guidance from the release manager" - The prompt asks for a 2-3 paragraph narrative in the style of previous O3DE release notes
- The LLM's output is cleaned (preamble/dividers stripped, HTML tag openers escaped) and replaces the
<!-- TODO -->placeholder
Default command: ollama run --nowordwrap qwen2.5:14b (Ollama with Qwen 2.5 14B). Override with --summary-cmd. The default targets a ~12GB VRAM budget so it works on a typical workstation; bump up to qwen2.5:32b if you have the headroom, or use claude -p for the highest quality.
Supported LLM options:
| Command | Type | Quality | Requirements |
|---|---|---|---|
claude -p |
Cloud | Highest | Claude CLI authenticated |
ollama run --nowordwrap qwen2.5:32b |
Local | Highest local | Ollama, ~24GB VRAM |
ollama run --nowordwrap qwen2.5:14b |
Local | High | Ollama, ~12GB VRAM (default) |
ollama run --nowordwrap mistral |
Local | Good | Ollama, ~6GB VRAM |
Requirements for custom commands: Must read the prompt from stdin and write the response to stdout. LLM preamble text (e.g., "Here's the summary:") and --- dividers are automatically stripped from the output.
When disabled (default): A placeholder intro and <!-- TODO --> comment are inserted for manual writing.
A CycloneDX 1.5 SBOM is maintained at sbom.cdx.json. It is automatically regenerated by a GitHub Action on every push to main that changes Python source files.
To regenerate locally:
make sbom # rewrite sbom.cdx.json (no-op if already current)
make sbom-check # exit non-zero if it is stale; run in CIThe SBOM captures:
- Project metadata (name, version, license, repository URL)
- Python stdlib modules used as dependencies, discovered by parsing the source
with
astrather than from a hand-maintained list that can drift - SHA-256 hashes of all source files for integrity verification
bom-refidentifiers on every component so the dependency graph actually resolves- Explicit declaration of zero external dependencies
Determinism: the substantive document is a pure function of repository
content. It carries no wall-clock value and no running-interpreter version, so
two checkouts of the same commit produce identical output on any machine. Only
metadata.timestamp and the content-derived serialNumber vary, and both are
excluded from the comparison. That is what makes --check meaningful and stops
CI from committing a timestamp-only SBOM change on every push.
python -m pytest tests/ -v300+ unit tests covering input validation (including path-traversal edge cases), multi-repo path parsing, SIG categorization (including deterministic tiebreaks for both title and file-based heuristics), GraphQL variable shape, summary prompt building, summary generation (with timeout-bounds validation), LLM output cleaning, markdown rendering (including release-machinery filtering), incremental merging (with drop-warning behavior), dry-run, atomic I/O, stderr token redaction, PR body size capping, point-release tag parsing, sibling-tag discovery, merge-base extraction, cherry-pick container parsing, point-release audit sidecar generation, release-machinery classification, point-release awareness logging, merge-commit PR discovery, per-repo ref overrides and preflight ref resolution, render reconciliation accounting, markdown/HTML escaping (including the double-escape and raw-HTML regressions), subprocess timeout handling, atomic write permissions and durability, SBOM determinism and dependency-graph integrity, and security controls.
A Makefile is provided for the common targets:
make test # run pytest
make sbom # regenerate sbom.cdx.json
make sbom-check # verify sbom.cdx.json is current (CI gate)
make lint # ruff (if installed)
make typecheck # mypy (if installed)
make check # all of the aboveThis tool is designed with OWASP and NIST SP 800-53 security controls. See ARCHITECTURE.md for the full security model, threat analysis, trust boundaries, and input validation specifications. To report a vulnerability, see SECURITY.md.
Key highlights:
- Zero external dependencies (Python stdlib only)
- All subprocess calls use list arguments (no
shell=True) - All subprocess output decoded with
encoding='utf-8', errors='replace' - All user inputs validated with regex before use
- GraphQL queries use server-side variables (
$owner,$name); no string interpolation - GitHub auth delegated to
ghCLI; stderr scrubbed for classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained (github_pat_) token shapes before logging - Atomic file writes prevent data corruption
- PR titles and descriptions sanitized for markdown and raw HTML (tag-like
<is escaped, so an<img onerror=...>in a PR title cannot become live HTML on a published page); PR bodies capped at 64KB before extraction - LLM summary output passes through the same HTML neutralization
- Every
git/ghinvocation converts timeouts and missing binaries into handled errors instead of aborting a run mid-flight - Atomic writes preserve the destination file's permissions and
fsyncbefore rename - Summary command runtime bounded (
--summary-timeout, default 300s, range 10–3600s) - CycloneDX SBOM with source file hashes for supply chain transparency
- GitHub Actions pinned to commit SHAs (not floating tags)
See CONTRIBUTING.md for the dev workflow, dual-license policy, and the GitHub Actions SHA-pinning policy.
Apache-2.0 OR MIT (see LICENSE.txt)