diff --git a/changelog.mdx b/changelog.mdx
index 24d0a20b..72d4a464 100644
--- a/changelog.mdx
+++ b/changelog.mdx
@@ -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 */}
+
+ ## 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 |
+ | `resq-tui` | 0.1.18 |
+ | `resq-dsa` | 0.1.4 |
+ | `resq-ai` | 0.1.3 |
+
+
## TypeScript
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
-
+
**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.
@@ -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 |
@@ -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 |
diff --git a/scripts/build_changelog.py b/scripts/build_changelog.py
index b64703f0..57db070d 100644
--- a/scripts/build_changelog.py
+++ b/scripts/build_changelog.py
@@ -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)
@@ -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,
@@ -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
+ 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:
+ 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:
@@ -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]):
@@ -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)