Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ in `automation/changelog-notes.md`.

{/* CHANGELOG:AUTOGEN:START — edit scripts/build_changelog.py or automation/changelog-notes.md, not this region */}

<Update label="September 2026" tags={["TypeScript", "Rust"]}>
## TypeScript

| Package | Version |
|---------|---------|
| `@resq-systems/map` | 4.0.0 |
| `@resq-systems/analytics` | 3.1.0 |
| `@resq-systems/email-templates` | 1.2.0 |
| `@resq-systems/ui` | 0.42.0 |
| `@resq-systems/constants` | 0.7.0 |
| `@resq-systems/nav` | 0.2.0 |

## Rust

| Package | Version |
|---------|---------|
| `resq-cli` | 0.4.7 |
| `resq-bin`, `resq-clean`, `resq-flame` | 0.1.26 |
| `resq-perf` | 0.1.22 |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `resq-tui` | 0.1.18 |
| `resq-dsa` | 0.1.4 |
| `resq-ai` | 0.1.3 |
</Update>

<Update label="August 2026" tags={["TypeScript", "Rust"]}>
## TypeScript

Expand All @@ -25,8 +49,9 @@ in `automation/changelog-notes.md`.
| `@resq-systems/security` | 2.1.0 |
| `@resq-systems/analytics`, `@resq-systems/http` | 2.0.2 |
| `@resq-systems/map` | 2.0.0 |
| `@resq-systems/email-templates` | 1.1.0 |
| `@resq-systems/ui` | 0.41.1 |
| `@resq-systems/helpers` | 0.6.0 |
| `@resq-systems/constants`, `@resq-systems/helpers` | 0.6.0 |
| `@resq-systems/rate-limiting` | 0.4.3 |
| `@resq-systems/telemetry` | 0.3.0 |
| `@resq-systems/types` | 0.2.0 |
Expand All @@ -35,9 +60,9 @@ in `automation/changelog-notes.md`.

| Package | Version |
|---------|---------|
| `resq-cli` | 0.4.0 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.19 |
| `resq-tui` | 0.1.11 |
| `resq-cli` | 0.4.1 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.20 |
| `resq-tui` | 0.1.12 |
| `resq-ai` | 0.1.1 |
</Update>

Expand Down Expand Up @@ -71,7 +96,7 @@ in `automation/changelog-notes.md`.

| Package | Version |
|---------|---------|
| `resq-bin`, `resq-clean`, `resq-deploy`, `resq-flame`, `resq-health`, `resq-logs`, `resq-perf` | 0.1.18 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.18 |
| `resq-tui` | 0.1.10 |
</Update>

Expand All @@ -80,7 +105,7 @@ in `automation/changelog-notes.md`.

| Package | Version |
|---------|---------|
| `resq-bin`, `resq-clean`, `resq-deploy`, `resq-flame`, `resq-health`, `resq-logs`, `resq-perf` | 0.1.17 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.17 |
| `resq-tui` | 0.1.9 |
</Update>

Expand All @@ -96,12 +121,12 @@ in `automation/changelog-notes.md`.

| Package | Version |
|---------|---------|
| `resq-bin`, `resq-clean`, `resq-deploy`, `resq-flame`, `resq-health`, `resq-logs`, `resq-perf` | 0.1.16 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.16 |
| `resq-tui` | 0.1.8 |
| `resq-dsa` | 0.1.3 |
</Update>

<Update label="April 2026" tags={["TypeScript", "Python", "Rust", ".NET"]}>
<Update label="April 2026" tags={["TypeScript", "Rust", ".NET"]}>
**Rust CLI tools renamed.** The seven CLI tools were renamed on 3 April 2026.
The old crate names stopped at `0.1.9` and are no longer published; the new
names continue the version series.
Expand All @@ -122,19 +147,12 @@ in `automation/changelog-notes.md`.
|---------|---------|
| `@resq-sw/logger`, `@resq-sw/rate-limiting` | 0.1.1 |

## Python

| Package | Version |
|---------|---------|
| `resq-mcp` | 1.3.4 |

## Rust

| Package | Version |
|---------|---------|
| `resq-cli` | 0.2.6 |
| `resq-bin`, `resq-clean`, `resq-deploy`, `resq-flame`, `resq-health`, `resq-logs`, `resq-perf` | 0.1.15 |
| `resq-bin-explorer`, `resq-cleanup`, `resq-deploy-cli`, `resq-flamegraph`, `resq-health-checker`, `resq-log-viewer`, `resq-perf-monitor` | 0.1.10 |
| `resq-bin`, `resq-clean`, `resq-flame`, `resq-perf` | 0.1.15 |
| `resq-tui` | 0.1.7 |
| `resq-dsa` | 0.1.2 |
| `resq-ai` | 0.1.0 |
Expand All @@ -158,7 +176,6 @@ in `automation/changelog-notes.md`.
| Package | Version |
|---------|---------|
| `resq-cli` | 0.2.3 |
| `resq-bin-explorer`, `resq-cleanup`, `resq-deploy-cli`, `resq-flamegraph`, `resq-health-checker`, `resq-log-viewer`, `resq-perf-monitor` | 0.1.8 |
| `resq-tui` | 0.1.4 |
| `resq-dsa` | 0.1.1 |

Expand Down
152 changes: 150 additions & 2 deletions scripts/build_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request

# repo -> (sidebar label/tag, how tags encode the package name, single-pkg name)
# "scoped" -> @scope/name@version (npm)
Expand All @@ -40,6 +43,24 @@
}
ECOSYSTEM_ORDER = ["npm", "pypi", "crates", "dotnet-sdk", "vcpkg"]

# repo -> (registry, package to query for "single"-mode repos)
#
# Tags are not proof of publication. A repo-level tag like v0.1.0 in a
# "single"-mode repo parses as a package release even when no such package
# version was ever pushed, which is how resq-mcp 0.1.0 and .NET 0.2.0 reached
# the changelog: neither exists (PyPI starts at 0.3.1, NuGet at 0.3.0). Every
# derived version is checked against the registry before it is rendered.
#
# vcpkg is a port collection with no version registry to query, so it is
# exempt rather than silently dropped.
REGISTRY = {
"npm": ("npm", None),
"pypi": ("pypi", "resq-mcp"),
"crates": ("crates", None),
"dotnet-sdk": ("nuget", "ResQ.Core"),
"vcpkg": (None, None),
}

# Scope renames: a package under the old scope is dropped from a month's table
# only when its counterpart under the new scope shipped that same month. This
# hides the abandoned-scope tail (e.g. @resq-sw/http's final release in July,
Expand Down Expand Up @@ -139,7 +160,125 @@ def load_releases(releases_file: str | None) -> list[dict]:
return out


def latest_by_month(releases: list[dict]) -> dict[str, dict[str, dict[str, str]]]:
_PUBLISHED: dict[tuple[str, str], set[str] | None] = {}


def _fetch(url: str) -> dict | None:
"""Return parsed JSON, {} for 404 (absent), or None if the lookup failed."""
req = urllib.request.Request(url, headers={"User-Agent": "resq-docs-changelog"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 404:
return {}
print(f"registry lookup failed ({e.code}): {url}", file=sys.stderr)
return None
except Exception as e: # network/DNS/timeout
print(f"registry lookup failed ({e}): {url}", file=sys.stderr)
return None


def published_versions(registry: str, pkg: str) -> set[str] | None:
"""Versions actually published for pkg, or None if the registry was unreachable."""
key = (registry, pkg)
if key in _PUBLISHED:
return _PUBLISHED[key]
if registry == "npm":
d = _fetch(f"https://registry.npmjs.org/{urllib.parse.quote(pkg, safe='@')}")
out = set((d or {}).get("versions", {})) if d is not None else None
elif registry == "pypi":
d = _fetch(f"https://pypi.org/pypi/{urllib.parse.quote(pkg)}/json")
out = set((d or {}).get("releases", {})) if d is not None else None
elif registry == "crates":
d = _fetch(f"https://crates.io/api/v1/crates/{urllib.parse.quote(pkg)}")
out = {v["num"] for v in (d or {}).get("versions", [])} if d is not None else None
elif registry == "nuget":
d = _fetch(
f"https://api.nuget.org/v3-flatcontainer/{urllib.parse.quote(pkg.lower())}/index.json"
)
out = set((d or {}).get("versions", [])) if d is not None else None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
out = None
_PUBLISHED[key] = out
return out


def _normalize_nuget_version(v: str) -> str:
"""NuGet normalizes versions: lowercased, leading zeros stripped, and a zero-valued
fourth (revision) segment dropped. Comparing in this form keeps a tag like `0.6.0.0`
matching the index's `0.6.0`, so a purely cosmetic difference never looks 'unpublished'.
"""
v = v.strip().lower()
core, _, pre = v.partition("-")
parts = core.split(".")
while len(parts) > 3 and parts[-1] in ("0", ""):
parts.pop()
parts = [str(int(p)) if p.isdigit() else p for p in parts]
core = ".".join(parts)
return f"{core}-{pre}" if pre else core


def _nuget_version_exists(pkg: str, ver: str) -> bool | None:
"""Confirm one NuGet version directly. The flat-container index can lag behind a
just-published version during propagation (NuGet/NuGetGallery#3455), so an index-only
miss is not proof of absence. Returns True (present), False (a 404 confirms absent), or
None (inconclusive -> the caller should fail open).
"""
p = urllib.parse.quote(pkg.lower())
v = urllib.parse.quote(_normalize_nuget_version(ver))
url = f"https://api.nuget.org/v3-flatcontainer/{p}/{v}/{p}.{v}.nupkg"
req = urllib.request.Request(
url, method="HEAD", headers={"User-Agent": "resq-docs-changelog"}
)
try:
with urllib.request.urlopen(req, timeout=20):
return True
except urllib.error.HTTPError as e:
return False if e.code == 404 else None
except Exception: # network/DNS/timeout
return None


def is_published(repo: str, pkg: str | None, ver: str) -> bool:
"""True unless the registry positively says this version was never published.

Unreachable registries and registry-less ecosystems fail open: a transient
network error must not silently erase real releases from the changelog.
"""
registry, probe = REGISTRY.get(repo, (None, None))
if registry is None:
return True
target = pkg or probe
if not target:
return True
known = published_versions(registry, target)
if known is None:
return True
if ver in known:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return True
if registry == "nuget":
# NuGet normalizes versions, and its flat-container index can lag behind a
# just-published version during propagation, so neither a formatting
# difference nor an index-only miss proves the release never shipped. Match
# on the normalized form, then, if still absent, confirm the exact version
# directly before dropping it.
norm = _normalize_nuget_version(ver)
if any(_normalize_nuget_version(k) == norm for k in known):
return True
if _nuget_version_exists(target, ver) is not False:
return True
print(
f"dropping unpublished {repo} release: {target} {ver} "
f"(not in {registry})",
file=sys.stderr,
)
return False


def latest_by_month(
releases: list[dict], verify: bool = True
) -> dict[str, dict[str, dict[str, str]]]:
"""month 'YYYY-MM' -> repo -> package -> highest version released that month."""
tree: dict[str, dict[str, dict[str, str]]] = {}
for rel in releases:
Expand All @@ -150,6 +289,8 @@ def latest_by_month(releases: list[dict]) -> dict[str, dict[str, dict[str, str]]
if not parsed:
continue
pkg, ver = parsed
if verify and not is_published(repo, pkg, ver):
continue
month = rel["published_at"][:7]
by_pkg = tree.setdefault(month, {}).setdefault(repo, {})
if pkg not in by_pkg or version_key(ver) > version_key(by_pkg[pkg]):
Expand Down Expand Up @@ -254,11 +395,18 @@ def main() -> int:
parser.add_argument(
"--check", action="store_true", help="exit 1 if changelog.mdx is not up to date"
)
parser.add_argument(
"--no-verify-registry",
action="store_true",
help="skip checking each version against its package registry "
"(implied by --releases-file, which is for offline runs)",
)
args = parser.parse_args()

root = args.root.resolve()
releases = load_releases(args.releases_file)
tree = latest_by_month(releases)
verify = not (args.no_verify_registry or args.releases_file)
tree = latest_by_month(releases, verify=verify)
notes = load_notes(root)
region = render(tree, notes)

Expand Down
Loading