diff --git a/AGENTS.md b/AGENTS.md index fe50c53..f2130ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,17 @@ Post-stable invariants: documents must contain no exact CometAPI patch or recovery identity. The document/version checker must fail before merge or release when this boundary is violated. +12. Keep `CHANGELOG.md` release-only: do not maintain an unmanaged `Unreleased` + section. Record changes in Conventional Commits and let Release Please own + the newest canonical dated release section after the changelog preamble. + The version gate must reject any `Unreleased` level-two heading before merge + or release. +13. A validated release-evidence block binds one canonical publication workflow + run to its machine-readable identity marker and may contain no other Actions + run URL. Keep preparatory implementation, CI, Release Please, failed-attempt, + and recovery history outside that block. The document gate must reject + non-canonical, wrapped, malformed, or contradictory workflow URLs regardless + of prose or Markdown labeling. ## Repository independence diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 806b951..3f560a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,9 +140,13 @@ uses `node24`. The workflow semantic contract fixes that SHA and runtime disposition so GitHub does not need to force a deprecated Node 20 action onto a newer runtime. -The version gate accepts Release Please's native linked, dated changelog heading -as well as the existing legacy dated form. It validates repository, previous -tag, candidate tag, and calendar date rather than rewriting generated history. +The changelog is release-only. Release Please owns the newest canonical dated +section immediately after its preamble; contributors record pending changes in +Conventional Commits and never maintain an `Unreleased` placeholder. The +version gate rejects that structurally incompatible placeholder, accepts Release +Please's native linked form and legacy dated history, and validates repository, +previous tag, candidate tag, and calendar date without rewriting generated +history. The PyPI publisher remains directly in `publish.yml` and is pinned to its reviewed Node 24 maintenance release. Pinning its exact SHA prevents a syntactic @@ -159,9 +163,13 @@ idempotent, while retrying immutable tag or GitHub Release creation could leave ambiguous external state and is forbidden. Immutable run, tag, commit, registry, and digest records live only in the -validated release-evidence blocks in `ROADMAP.md` and `RELEASING.md`. -Architecture documents mechanisms and boundaries, not a second historical -ledger. +validated release-evidence blocks in `ROADMAP.md` and `RELEASING.md`. Each +block binds only the canonical publication run through its machine-readable +identity; preparatory implementation, CI, Release Please, failed-attempt, and +recovery history stays outside the block. The checker rejects every other run +identity plus wrapped or malformed Actions URLs, so Markdown wording cannot +disguise a contradictory workflow. Architecture documents mechanisms and +boundaries, not a second historical ledger. The scheduled/manual default-branch smoke is an operational canary only; it does not prove the release commit. `COMETAPI_KEY` is exposed only to the diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e3a0d..b3092b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ All notable changes to this project are documented in this file. The project follows Semantic Versioning and uses Conventional Commits for release-PR automation. -## [Unreleased] - ## [0.1.3] - 2026-07-30 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edefcb2..4c5dda3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,9 @@ legacy client aliases to a 0.1 change. Prefer the smallest complete change. Preserve precise type hints, update documentation and the compatibility matrix when public behavior changes, and -add a changelog entry for user-visible changes. +describe user-visible changes through Conventional Commits. Do not maintain an +`Unreleased` changelog section; Release Please owns canonical dated release +history. ## Commit and pull-request guidance diff --git a/RELEASING.md b/RELEASING.md index d604ecc..88da093 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -198,6 +198,18 @@ branch and missing pull-request permission. Treat that run as isolated transport-failure evidence; do not rerun it or reinterpret it as an authorization failure. +`CHANGELOG.md` is release-only: do not maintain an `Unreleased` placeholder. +Conventional Commits carry pending changes, and Release Please owns the newest +canonical dated section immediately after the preamble. The version gate rejects +any unmanaged `Unreleased` level-two heading so the generated layout remains +valid on every patch release. + +Each validated release-evidence block contains the immutable release identity +and only its canonical publication workflow. Preparatory implementation CI, +Release Please, failed-publication, and recovery history remains outside the +block. The document gate rejects every different run identity and every wrapped +or malformed Actions URL regardless of prose or Markdown labeling. + Release mode (`check_version.py --require-releasable-docs`) also fails closed until project authorship, the canonical GitHub repository URL, the copyright holder, security and support contacts, a publication-neutral README, and a @@ -471,16 +483,17 @@ authorized. ### Completed first stable release evidence +- Selector-descendant fix [PR #23](https://github.com/cometapi-dev/cometapi-python/pull/23) + passed [pull-request CI run 30358662050](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358662050), + squash-merged as `9cd60419130533d6920083e2f4bf295a3b5a4fd7`, and passed + [default-branch CI run 30358990834](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358990834). + - The immutable non-draft [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.0) and lightweight tag `v0.1.0` resolve to release commit `6f42981edcc6c252f8db997606671c3da84d1dd8` on protected `main`. -- Selector-descendant fix [PR #23](https://github.com/cometapi-dev/cometapi-python/pull/23) - passed [pull-request CI run 30358662050](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358662050), - squash-merged as `9cd60419130533d6920083e2f4bf295a3b5a4fd7`, and passed - [default-branch CI run 30358990834](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358990834). - Fresh first-attempt [recovery run 30359383715](https://github.com/cometapi-dev/cometapi-python/actions/runs/30359383715) passed immutable identity verification, the shared selector, an exact rebuild, @@ -506,9 +519,6 @@ authorized. ### Completed configuration maintenance release evidence - - - - Configuration fix [PR #25](https://github.com/cometapi-dev/cometapi-python/pull/25) passed [pull-request CI run 30419881169](https://github.com/cometapi-dev/cometapi-python/actions/runs/30419881169) and squash-merged as `d02b1dba277ac72229b772d29ea1870b569edd88`. @@ -528,10 +538,15 @@ authorized. received human owner approval at that exact head, and squash-merged as `576e7503a0a8c1103faca5143e4b8d576f8e8b44`. The release commit passed [default-branch CI run 30429821579](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821579). + + + + - First-attempt [release run 30429821548](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821548) created and verified immutable non-draft release [v0.1.1](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.1) - at the exact release commit, rebuilt and clean-installed both artifacts, + at release commit `576e7503a0a8c1103faca5143e4b8d576f8e8b44`, rebuilt and + clean-installed both artifacts, passed the four-request exact-release live suite, received protected `pypi` approval, published directly from top-level `publish.yml` by OIDC with attestations, and passed public registry verification. @@ -555,9 +570,6 @@ authorized. ### Completed release-metadata maintenance evidence - - - - Metadata and runtime [PR #29](https://github.com/cometapi-dev/cometapi-python/pull/29) made packaged long descriptions release-neutral, added artifact assertions, pinned Release Please v5 to its Node 24 action commit, passed @@ -581,11 +593,16 @@ authorized. received human owner approval at that exact head, and squash-merged as release commit `710c56491d9ef5f47cccff3ce837ab7e799455b0`. The release commit passed [default-branch CI run 30515861285](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861285). + + + + - First-attempt [release run 30515861246](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861246) created and independently verified immutable non-draft release [v0.1.2](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.2) - at the exact release commit, rebuilt and clean-installed both artifacts, + at release commit `710c56491d9ef5f47cccff3ce837ab7e799455b0`, rebuilt and + clean-installed both artifacts, passed the bounded four-request exact-release live suite, received protected `pypi` approval, published directly from top-level `publish.yml` by OIDC with attestations, and passed public registry verification. @@ -610,9 +627,6 @@ authorized. ### Completed release-claim maintenance evidence - - - - Mutable-release-claim [PR #34](https://github.com/cometapi-dev/cometapi-python/pull/34) removed the published patch number from persistent guidance and extended the existing document/version checker through pull-request CI, release source and @@ -636,11 +650,16 @@ authorized. received exact-head human owner approval, and squash-merged as release commit `45429f373bbd11314ec43ba81904fdbb78db2522`. The release commit passed [default-branch CI run 30550533622](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550533622). + + + + - First-attempt [release run 30550536000](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550536000) created and independently verified immutable non-draft release [v0.1.3](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.3) - and its lightweight tag at the exact release commit, rebuilt and + and its lightweight tag at release commit + `45429f373bbd11314ec43ba81904fdbb78db2522`, rebuilt and clean-installed both artifacts, passed the bounded four-request exact-release live suite, received protected `pypi` approval, published directly from top-level `publish.yml` by OIDC with attestations, and passed public registry diff --git a/ROADMAP.md b/ROADMAP.md index 165a002..6c681cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -414,18 +414,19 @@ Registry Alpha evidence block. Accepted release evidence: - - - - Metadata fix [PR #16](https://github.com/cometapi-dev/cometapi-python/pull/16) merged as `6344c2d0e2e975360b42c887275c1950b82918ee`; recovery contract [PR #17](https://github.com/cometapi-dev/cometapi-python/pull/17) merged as release commit `31b68904141489ca04932edbf305ccf88af09372`. Final [default-branch CI run 30261497883](https://github.com/cometapi-dev/cometapi-python/actions/runs/30261497883) passed. + + + + - Annotated tag `v0.1.0-alpha.1+recovery.1` has tag object - `fdc4a6cce31f4534f83903f3f95e7757a4d4049f` and peels to the release - commit. The corresponding + `fdc4a6cce31f4534f83903f3f95e7757a4d4049f` and peels to release commit + `31b68904141489ca04932edbf305ccf88af09372`. The corresponding [immutable GitHub prerelease](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.0-alpha.1%2Brecovery.1) is release `360377046`. - [Release workflow run 30261746138](https://github.com/cometapi-dev/cometapi-python/actions/runs/30261746138) @@ -447,12 +448,6 @@ Accepted release evidence: ## First stable OpenAI protocol foundation - - - -Canonical [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.0) -and [PyPI release](https://pypi.org/project/cometapi/0.1.0/) identity. - Stable 0.1 retains the alpha surface. Its additional exit criteria are: - Blocking Python runtime matrix for every supported runtime. @@ -467,9 +462,9 @@ Stable 0.1 retains the alpha surface. Its additional exit criteria are: independent post-publication install/import/mocked-call check. - No complete credential appears in source, fixtures, artifacts, or logs. -The first stable publication attempt created immutable release `v0.1.0` at -`6f42981edcc6c252f8db997606671c3da84d1dd8` and passed default-branch CI plus -exact artifact construction, but [stopped before any live request](https://github.com/cometapi-dev/cometapi-python/actions/runs/30348177128) +The first stable publication attempt created the immutable stable release and +passed default-branch CI plus exact artifact construction, but +[stopped before any live request](https://github.com/cometapi-dev/cometapi-python/actions/runs/30348177128) because the reusable workflow caller omitted `secrets: inherit` and GitHub resolved the `live-smoke` environment secret as empty. PyPI publication and registry verification were skipped. [PR #21](https://github.com/cometapi-dev/cometapi-python/pull/21) @@ -498,7 +493,7 @@ correct top-level workflow identity. GitHub nevertheless propagated the intentionally skipped Release Please ancestry to the selector descendants, so build, live smoke, publication, and registry verification were all skipped and the overall run incorrectly reported success. No live request or registry side -effect occurred, and PyPI still returned 404 for `cometapi==0.1.0`. The +effect occurred, and PyPI still returned 404 for the stable distribution. The permanent control-flow fix makes every selector descendant explicitly evaluate skipped ancestry while rejecting cancellation and reruns and requiring every direct dependency to succeed. @@ -508,6 +503,13 @@ conditions in the semantic checker and mutation tests, passed [pull-request CI run 30358662050](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358662050), squash-merged as `9cd60419130533d6920083e2f4bf295a3b5a4fd7`, and passed [default-branch CI run 30358990834](https://github.com/cometapi-dev/cometapi-python/actions/runs/30358990834). + + + + +Canonical [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.0) +and [PyPI release](https://pypi.org/project/cometapi/0.1.0/) identity at release +commit `6f42981edcc6c252f8db997606671c3da84d1dd8`. Fresh first-attempt [recovery run 30359383715](https://github.com/cometapi-dev/cometapi-python/actions/runs/30359383715) then passed the selector, exact artifact rebuild, bounded four-request live @@ -527,10 +529,8 @@ the only remaining release-related repository variable. ## Configuration validation maintenance - - - -Maintenance release `0.1.1` rejects explicitly blank API keys and base URLs, +The configuration-validation maintenance release rejects explicitly blank API +keys and base URLs, treats a blank environment key as missing, and uses the default CometAPI URL for a blank environment base URL. It trims surrounding string whitespace, including the Node-compatible byte-order mark boundary, without changing @@ -558,11 +558,15 @@ and received an exact-head human owner approval. It squash-merged as release commit `576e7503a0a8c1103faca5143e4b8d576f8e8b44`, which passed [default-branch CI run 30429821579](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821579). + + + First-attempt [release run 30429821548](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821548) created and verified the immutable non-draft [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.1) -and lightweight tag `v0.1.1` at that exact release commit. The run selected the +and lightweight tag `v0.1.1` at release commit +`576e7503a0a8c1103faca5143e4b8d576f8e8b44`. The run selected the verified release identity, rebuilt and independently installed both artifacts, and passed the exact-release live suite with four serial requests, at most 16 output tokens per request, a 30-second request timeout, and stop-on-first-failure. @@ -589,11 +593,8 @@ variables are absent. No recovery tag or recovery workflow was used. ## Release metadata and transport maintenance - - - [Release Please run 30509764960](https://github.com/cometapi-dev/cometapi-python/actions/runs/30509764960) -failed while maintaining the `0.1.2` release PR. The pinned v5 action had built +failed while maintaining the release-metadata patch PR. The pinned v5 action had built the candidate and reached its PR write boundary when Undici/global `fetch` reported `other side closed`. It created or updated no branch, pull request, tag, GitHub Release, live request, PyPI file, or other registry state. Read-only @@ -630,11 +631,15 @@ received human owner approval at that exact head, and squash-merged as release commit `710c56491d9ef5f47cccff3ce837ab7e799455b0`. The release commit passed [default-branch CI run 30515861285](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861285). + + + First-attempt [release run 30515861246](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861246) created and verified the immutable non-draft [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.2) -and lightweight tag `v0.1.2` at that exact release commit. It rebuilt and +and lightweight tag `v0.1.2` at release commit +`710c56491d9ef5f47cccff3ce837ab7e799455b0`. It rebuilt and clean-installed the exact artifacts, passed the four-request exact-release live suite, received protected `pypi` approval, published directly from top-level `publish.yml` through OIDC with attestations, and passed public registry @@ -663,9 +668,6 @@ used for `0.1.2`. ## Durable release-claim maintenance - - - [Implementation PR #34](https://github.com/cometapi-dev/cometapi-python/pull/34) removed the mutable published-patch claim from persistent guidance and extended the existing document/version checker across pull-request CI, release source and @@ -692,11 +694,15 @@ received human owner approval, and squash-merged as release commit `45429f373bbd11314ec43ba81904fdbb78db2522`. The release commit passed [default-branch CI run 30550533622](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550533622). + + + First-attempt [release run 30550536000](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550536000) created and independently verified the immutable non-draft [GitHub release](https://github.com/cometapi-dev/cometapi-python/releases/tag/v0.1.3) -and lightweight tag `v0.1.3` at that exact release commit. It rebuilt and +and lightweight tag `v0.1.3` at release commit +`45429f373bbd11314ec43ba81904fdbb78db2522`. It rebuilt and clean-installed the exact artifacts, passed the bounded four-request exact-release live suite, received protected `pypi` approval, published directly from top-level `publish.yml` by OIDC with attestations, and passed diff --git a/pyproject.toml b/pyproject.toml index 655d21a..21b95ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ Security = "https://github.com/cometapi-dev/cometapi-python/security/advisories/ dev = [ "build>=1.3.0,<2.0.0", "httpx>=0.28.1,<1.0.0", + "markdown-it-py>=4.0.0,<5.0.0", "packaging>=24.2,<27.0", "pyright>=1.1.408,<2.0.0", "pytest>=8.3.5,<10.0.0", diff --git a/scripts/_checks.py b/scripts/_checks.py index 8a79a66..e40f520 100644 --- a/scripts/_checks.py +++ b/scripts/_checks.py @@ -8,6 +8,7 @@ import re import sys import unicodedata +from collections import Counter from collections.abc import Iterable from dataclasses import dataclass from datetime import date @@ -82,6 +83,9 @@ r"sdist-sha256=(?P[0-9a-f]{64}) -->$" ) _ANY_RELEASE_EVIDENCE_IDENTITY = re.compile(r"(?m)^.*cometapi-release-identity.*$") +_ANY_RELEASE_EVIDENCE_WORKFLOW_REFERENCE = re.compile( + r"(?m)^.*cometapi-release-workflow-reference.*$" +) _ANY_RELEASE_EVIDENCE_MARKER = re.compile(r"(?m)^.*cometapi-release-evidence:.*$") _EXACT_VERSION = ( r"(?:v\s*)?\d+\s*\.\s*\d+\s*\.\s*\d+" @@ -130,8 +134,15 @@ _HTTP_URL = re.compile(r"https?://[^\s<>)\]]+", re.IGNORECASE) _RECOVERY_TAGS = {"0.1.0a1": "v0.1.0-alpha.1+recovery.1"} _FULL_COMMIT = re.compile(r"(?\d+)(?:/attempts/(?P\d+))?", + re.IGNORECASE, +) +_CANONICAL_ACTIONS_URL = re.compile( + rf"(?[1-9]\d*)" + r"(?:/attempts/(?P[1-9]\d*))?" + r"(?=$|[\s<>\"')\]}]|[.,;:!?](?=$|\s))" ) _WHEEL_DIGEST = re.compile( r"\bwheel\s+sha256\b[^0-9a-f]{0,96}(?P[0-9a-f]{64})(?![0-9a-f])", @@ -504,6 +515,15 @@ def _identity_violations( sdist_sha256=match.group("sdist"), ) findings: list[tuple[int, str]] = [] + if _ANY_RELEASE_EVIDENCE_WORKFLOW_REFERENCE.search(body) is not None: + findings.append( + ( + line, + f"release-evidence block for {version} contains an obsolete workflow-reference " + "marker; remove it and keep preparatory workflow history outside the immutable " + "evidence block", + ) + ) expected_tag = _canonical_release_tag(version) if identity.tag != expected_tag: findings.append( @@ -531,8 +551,9 @@ def _identity_violations( ), "exact release commit": re.compile(re.escape(identity.commit), re.IGNORECASE), "exact release workflow URL": re.compile( - rf"{re.escape(CANONICAL_REPOSITORY)}/actions/runs/{identity.workflow_run}" + rf"(?\"')\]}]|[.,;:!?](?=$|\s))" ), "exact wheel SHA256": re.compile( rf"\bwheel\s+sha256\b[^0-9a-f]{{0,96}}{identity.wheel_sha256}(?![0-9a-f])", @@ -569,26 +590,8 @@ def _identity_violations( before_commit, ): release_commit_values.add(commit.group(0).lower()) - release_run_values: set[str] = set() - for run in _ACTIONS_RUN.finditer(prose): - line_start = prose.rfind("\n", 0, run.start()) + 1 - prior_line_start = prose.rfind("\n", 0, max(0, line_start - 1)) + 1 - context = prose[prior_line_start : run.start()] - label = re.search(r"(?i)\[([^\]]+)\]\([^\n]*$", context) - label_text = label.group(1) if label is not None else context.splitlines()[-1] - if re.search( - r"(?i)\b(?:release|publish(?:ing)?|publication|registry)" - r"(?:[ -]+(?:workflow|job|pipeline))?[ -]+run\b" - r"|\b(?:release|publish(?:ing)?|publication|registry)" - r"(?:[ -]+(?:job|pipeline))?[ -]+workflow\b" - r"|\bworkflow[ -]+run\b" - r"|\bgithub[ -]+actions[ -]+run\b", - label_text, - ): - release_run_values.add(run.group(0).split("/actions/runs/", 1)[1].split("/", 1)[0]) labeled_values = ( ("release commit", release_commit_values, identity.commit), - ("release workflow run", release_run_values, identity.workflow_run), ( "wheel SHA256", { @@ -670,6 +673,99 @@ def _identity_violations( return identity, findings +def _canonical_actions_run_violations( + body: str, + version: str, + expected_run: str, + line: int, +) -> list[tuple[int, str]]: + """Require every Actions URL in immutable evidence to name one canonical run.""" + findings: list[tuple[int, str]] = [] + normalized = unicodedata.normalize("NFKC", body) + direct = normalized + for _ in range(3): + decoded = html.unescape(direct) + if decoded == direct: + break + direct = decoded + markdown_unescaped = re.sub(r"\\([/\\.:?&=%#])", r"\1", direct) + browser_normalized = re.sub( + r"(?i)(https?://[^\s<>)\]]*)\\([^\s<>)\]]*)", + lambda match: match.group(0).replace("\\", "/"), + markdown_unescaped, + ) + + path_matches = list(_ACTIONS_PATH.finditer(direct)) + canonical_matches = list(_CANONICAL_ACTIONS_URL.finditer(direct)) + malformed = any( + not any( + url.start() <= path.start() and path.end() <= url.end() for url in canonical_matches + ) + for path in path_matches + ) + + percent_decoded = direct + for _ in range(3): + decoded = unquote(percent_decoded) + if decoded == percent_decoded: + break + percent_decoded = decoded + direct_paths = Counter((match.group("run"), match.group("attempt")) for match in path_matches) + decoded_paths = Counter( + (match.group("run"), match.group("attempt")) + for match in _ACTIONS_PATH.finditer(percent_decoded) + ) + malformed = malformed or any( + count > direct_paths[identity] for identity, count in decoded_paths.items() + ) + normalized_paths = Counter( + (match.group("run"), match.group("attempt")) for match in _ACTIONS_PATH.finditer(normalized) + ) + malformed = malformed or any( + count > normalized_paths[identity] for identity, count in direct_paths.items() + ) + unescaped_paths = Counter( + (match.group("run"), match.group("attempt")) + for match in _ACTIONS_PATH.finditer(markdown_unescaped) + ) + malformed = malformed or any( + count > normalized_paths[identity] for identity, count in unescaped_paths.items() + ) + browser_paths = Counter( + (match.group("run"), match.group("attempt")) + for match in _ACTIONS_PATH.finditer(browser_normalized) + ) + malformed = malformed or any( + count > normalized_paths[identity] for identity, count in browser_paths.items() + ) + + if malformed: + findings.append( + ( + line, + f"release-evidence block for {version} contains a non-canonical Actions URL; " + "use the exact repository /actions/runs/ URL with an optional " + "/attempts/ suffix", + ) + ) + run_values = ( + {run for run, _attempt in direct_paths} + | {run for run, _attempt in decoded_paths} + | {run for run, _attempt in unescaped_paths} + | {run for run, _attempt in browser_paths} + ) + if run_values - {expected_run}: + findings.append( + ( + line, + f"release-evidence block for {version} contains a release workflow run that " + "contradicts its release-identity marker; keep preparatory workflow history " + "outside the immutable evidence block", + ) + ) + return findings + + def _evidence_block_violations( document: str, text: str, @@ -765,6 +861,14 @@ def _evidence_block_violations( ) findings.extend(identity_findings) if identity is not None: + findings.extend( + _canonical_actions_run_violations( + body, + version, + identity.workflow_run, + start_line, + ) + ) if version in identities: findings.append((start_line, f"duplicate release-evidence block for {version}")) else: diff --git a/scripts/check_version.py b/scripts/check_version.py index 53d1e77..7814c28 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -4,14 +4,20 @@ from __future__ import annotations import argparse +import html import re +import unicodedata from dataclasses import dataclass from datetime import date +from html.parser import HTMLParser from itertools import pairwise from pathlib import Path from typing import cast from urllib.parse import unquote, urlsplit +from markdown_it import MarkdownIt +from markdown_it.token import Token + try: from ._checks import ( CANONICAL_AUTHOR, @@ -76,11 +82,6 @@ rf"^##[ \t]+\[?(?P{_CHANGELOG_VERSION})(?:\]|[ \t])", re.IGNORECASE, ) -_CHANGELOG_UNRELEASED_HEADING = re.compile(r"^##[ \t]+\[Unreleased\][ \t]*$") -_CHANGELOG_UNRELEASED_CANDIDATE = re.compile( - r"^##[ \t]+\[?Unreleased(?:\]|[ \t])", - re.IGNORECASE, -) _CHANGELOG_REFERENCE_CANDIDATE = re.compile( rf"^\[(?P{_CHANGELOG_VERSION})\]:[ \t]*(?P\S+)[ \t]*$", re.IGNORECASE, @@ -177,54 +178,161 @@ def _compare_tags(url: str) -> tuple[str, str] | None: def _changelog_preamble_end(text: str) -> int: - heading = re.search(r"(?m)^##[ \t]+.*$", text) + heading = re.search(r"(?m)^ {0,3}##(?!#)[ \t]+.*$", text) return heading.start() if heading is not None else len(text) -def _changelog_unreleased_regions(text: str) -> list[tuple[int, int]]: - level_two = list(re.finditer(r"(?m)^##[ \t]+.*$", text)) - regions: list[tuple[int, int]] = [] - for index, heading in enumerate(level_two): - if _CHANGELOG_UNRELEASED_HEADING.fullmatch(heading.group(0)) is None: - continue - end = level_two[index + 1].start() if index + 1 < len(level_two) else len(text) - regions.append((heading.start(), end)) - return regions +def _visible_inline_text(tokens: list[Token]) -> str: + visible: list[str] = [] + for token in tokens: + if token.type in {"text", "code_inline"}: + visible.append(token.content) + elif token.type in {"softbreak", "hardbreak"}: + visible.append(" ") + elif token.type == "html_inline" and re.fullmatch( + r"(?is)", + token.content.strip(), + ): + visible.append(" ") + elif token.type == "image": + visible.append(_visible_inline_text(token.children or []) or token.content) + return "".join(visible) + + +class _RawH2Collector(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.headings: list[tuple[int, str]] = [] + self._line: int | None = None + self._parts: list[str] = [] + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + del attrs + if tag.casefold() == "h2" and self._line is not None: + self._close_heading() + if tag.casefold() == "h2": + self._line = self.getpos()[0] + self._parts = [] + elif tag.casefold() == "br" and self._line is not None: + self._parts.append(" ") + + def handle_startendtag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + self.handle_starttag(tag, attrs) + + def handle_endtag(self, tag: str) -> None: + if tag.casefold() == "h2" and self._line is not None: + self._close_heading() + + def handle_data(self, data: str) -> None: + if self._line is not None: + self._parts.append(data) + + @property + def collecting(self) -> bool: + return self._line is not None + + def handle_entityref(self, name: str) -> None: + if self._line is not None: + self._parts.append(html.unescape(f"&{name};")) + + def handle_charref(self, name: str) -> None: + if self._line is not None: + self._parts.append(html.unescape(f"&#{name};")) + + def close(self) -> None: + super().close() + if self._line is not None: + self._close_heading() + + def _close_heading(self) -> None: + assert self._line is not None + self.headings.append((self._line, "".join(self._parts))) + self._line = None + self._parts = [] + + +def _visible_heading_is_unreleased(label: str) -> bool: + label = html.unescape(unicodedata.normalize("NFKC", label)) + label = "".join( + "" + if unicodedata.category(value) == "Cf" + or (unicodedata.category(value) == "Mn" and unicodedata.combining(value) == 0) + else value + for value in label + ) + words = re.findall(r"[A-Za-z]+", label) + return bool(words and words[0].casefold() == "unreleased") + + +def _unreleased_heading_lines(text: str) -> list[int]: + tokens = MarkdownIt("commonmark", {"html": True}).parse(text) + lines: set[int] = set() + for index, token in enumerate(tokens): + if token.type == "heading_open" and token.tag == "h2" and token.map is not None: + inline = tokens[index + 1] if index + 1 < len(tokens) else None + if ( + inline is not None + and inline.type == "inline" + and _visible_heading_is_unreleased(_visible_inline_text(inline.children or [])) + ): + lines.add(token.map[0] + 1) + elif token.type == "inline" and token.map is not None: + collector = _RawH2Collector() + for child in token.children or []: + if child.type == "html_inline": + collector.feed(child.content) + elif collector.collecting and child.type in {"text", "code_inline"}: + collector.handle_data(child.content) + elif collector.collecting and child.type in {"softbreak", "hardbreak"}: + collector.handle_data(" ") + collector.close() + for relative_line, label in collector.headings: + if _visible_heading_is_unreleased(label): + lines.add(token.map[0] + relative_line) + elif token.type == "html_block" and token.map is not None: + collector = _RawH2Collector() + collector.feed(token.content) + collector.close() + for relative_line, label in collector.headings: + if _visible_heading_is_unreleased(label): + lines.add(token.map[0] + relative_line) + return sorted(lines) def _changelog_mutable_region_violations( text: str, ) -> list[str]: - regions = [(0, _changelog_preamble_end(text)), *_changelog_unreleased_regions(text)] findings: list[str] = [] seen: set[tuple[int, str]] = set() - for start, end in regions: - region = text[start:end] - base_line = text.count("\n", 0, start) - for relative_line, label in exact_release_version_violations( - "CHANGELOG-Unreleased.md", - region, - "0.1.0", - ): - finding = ( - base_line + relative_line, - label, - ) - if finding in seen: - continue - seen.add(finding) - findings.append( - f"CHANGELOG.md:{finding[0]}: {finding[1]} in preamble or Unreleased prose; " - f"{EXACT_RELEASE_VERSION_FIX}" - ) + preamble = text[: _changelog_preamble_end(text)] + for line, label in exact_release_version_violations( + "CHANGELOG-preamble.md", + preamble, + "0.1.0", + ): + finding = (line, label) + if finding in seen: + continue + seen.add(finding) + findings.append( + f"CHANGELOG.md:{line}: {label} in changelog preamble; {EXACT_RELEASE_VERSION_FIX}" + ) return findings def _parse_changelog_releases(text: str) -> list[_ChangelogRelease]: + unreleased_lines = set(_unreleased_heading_lines(text)) text = _mask_nonprose_changelog_regions(text) releases: list[_ChangelogRelease] = [] violations: list[str] = [] - unreleased_lines: list[int] = [] offset = 0 for line_number, raw_line in enumerate(text.splitlines(keepends=True), start=1): line = raw_line.rstrip("\r\n") @@ -259,21 +367,16 @@ def _parse_changelog_releases(text: str) -> list[_ChangelogRelease]: "heading: '## [version] - YYYY-MM-DD' or Release Please's canonical " "compare-link form" ) - elif _CHANGELOG_UNRELEASED_HEADING.fullmatch(line) is not None: - unreleased_lines.append(line_number) - elif _CHANGELOG_UNRELEASED_CANDIDATE.match(line) is not None: + if line_number in unreleased_lines: violations.append( - f"CHANGELOG.md:{line_number}: Unreleased heading must be exactly '## [Unreleased]'" + f"CHANGELOG.md:{line_number}: unmanaged Unreleased heading is forbidden; " + "remove it, record changes in Conventional Commits, and let Release Please " + "insert the newest canonical dated release section after the changelog preamble" ) offset += len(raw_line) if not releases: violations.append("CHANGELOG.md: has no canonical dated release heading") - if len(unreleased_lines) > 1: - violations.append("CHANGELOG.md: must contain at most one '## [Unreleased]' heading") - if unreleased_lines and releases and unreleased_lines[0] > releases[0].line: - violations.append("CHANGELOG.md: '## [Unreleased]' must precede every dated release") - seen_versions: dict[str, int] = {} for release in releases: previous_line = seen_versions.get(release.version) diff --git a/tests/test_changelog_gate.py b/tests/test_changelog_gate.py index 364b74a..17bcb57 100644 --- a/tests/test_changelog_gate.py +++ b/tests/test_changelog_gate.py @@ -4,12 +4,12 @@ import pytest -from scripts._checks import CheckError +from scripts._checks import CheckError, read_project_version from scripts.check_version import changelog_release_dates -def _history(*headings: str, preamble: str = "", unreleased: str = "") -> str: - sections = ["# Changelog", preamble, "## [Unreleased]", unreleased] +def _history(*headings: str, preamble: str = "") -> str: + sections = ["# Changelog", preamble] sections.extend(f"{heading}\n\n- Historical release." for heading in headings) return "\n\n".join(part for part in sections if part != "") + "\n" @@ -28,27 +28,31 @@ def test_changelog_parser_accepts_canonical_legacy_history() -> None: } -def test_changelog_parser_accepts_release_please_native_heading() -> None: +def test_changelog_parser_accepts_exact_release_please_layout() -> None: text = _history( - "## [0.1.3](https://github.com/cometapi-dev/cometapi-python/compare/" - "v0.1.2...v0.1.3) (2026-07-30)", - "## [0.1.2] - 2026-07-29", + "## [0.1.4](https://github.com/cometapi-dev/cometapi-python/compare/" + "v0.1.3...v0.1.4) (2026-07-31)", + "## [0.1.3] - 2026-07-30", ) - assert changelog_release_dates(text)["0.1.3"] == "2026-07-30" + assert changelog_release_dates(text) == { + "0.1.4": "2026-07-31", + "0.1.3": "2026-07-30", + } -@pytest.mark.parametrize("region", ["preamble", "unreleased"]) -def test_changelog_parser_rejects_mutable_exact_claim_outside_history(region: str) -> None: - kwargs = {region: "The current CometAPI PyPI release is 0.1.2."} - text = _history("## [0.1.3] - 2026-07-30", **kwargs) +def test_changelog_parser_rejects_mutable_exact_claim_in_preamble() -> None: + text = _history( + "## [0.1.3] - 2026-07-30", + preamble="The current CometAPI PyPI release is 0.1.2.", + ) with pytest.raises(CheckError) as caught: changelog_release_dates(text) message = str(caught.value) assert "CHANGELOG.md:" in message - assert "preamble or Unreleased prose" in message + assert "in changelog preamble" in message assert "version-neutral 0.1.x guidance" in message @@ -65,12 +69,102 @@ def test_changelog_parser_rejects_mutable_exact_claim_outside_history(region: st ], ) def test_changelog_parser_rejects_obfuscated_mutable_versions(claim: str) -> None: - text = _history("## [0.1.3] - 2026-07-30", unreleased=claim) + text = _history("## [0.1.3] - 2026-07-30", preamble=claim) + + with pytest.raises(CheckError, match="in changelog preamble"): + changelog_release_dates(text) + + +@pytest.mark.parametrize( + "heading", + [ + "## [Unreleased]", + "## Unreleased", + "## [unreleased]", + "## [ Unreleased ]", + "## [Unreleased] - pending", + " ## [Unreleased]", + " ## **Unreleased**", + "## [Un\u200breleased]", + "## Un**released**", + "## *Unreleased*", + "## [Un](https://example.invalid)released", + "## [Unreleased](https://example.invalid)", + "## `Un`released", + "## `Unreleased`", + "## Unreleased", + "## Unreleased", + "## Unreleased", + "## \uff35\uff4e\uff52\uff45\uff4c\uff45\uff41\uff53\uff45\uff44", + "## Un\u034freleased", + "Unreleased\n----------", + "> ## Unreleased", + "

Unreleased

", + "

Unreleased", + "

Unreleased

Archive

", + "

Unreleased

", + "Intro

Unreleased

", + ], +) +@pytest.mark.parametrize("position", ["before", "between", "after"]) +def test_changelog_parser_rejects_unmanaged_unreleased_heading( + heading: str, + position: str, +) -> None: + newest = "## [0.1.4] - 2026-07-31\n\n- Newest release." + previous = "## [0.1.3] - 2026-07-30\n\n- Previous release." + parts = { + "before": [heading, newest, previous], + "between": [newest, heading, previous], + "after": [newest, previous, heading], + }[position] + text = "# Changelog\n\n" + "\n\n".join(parts) + "\n" + + with pytest.raises(CheckError) as caught: + changelog_release_dates(text) + + message = str(caught.value) + assert "CHANGELOG.md:" in message + assert "unmanaged Unreleased heading is forbidden" in message + assert "remove it" in message + assert "Release Please" in message + + +@pytest.mark.parametrize("indent", ["", " ", " ", " "]) +def test_changelog_parser_rejects_rendered_unreleased_heading_indentation( + indent: str, +) -> None: + text = _history( + f"{indent}## [Un](https://example.invalid)released", + "## [0.1.3] - 2026-07-30", + ) - with pytest.raises(CheckError, match="preamble or Unreleased prose"): + with pytest.raises(CheckError, match="unmanaged Unreleased heading is forbidden"): changelog_release_dates(text) +@pytest.mark.parametrize( + "example", + [ + "Paragraph.\n\n ## Unreleased", + "```markdown\n## Unreleased\n```", + "### Unreleased", + "##Unreleased", + "## Un released", + "## Un
released", + "## Un*released", + "## [Current](https://example.invalid/Unreleased)", + '## Current', + "## `Current Unreleased`", + "## Unrelea\u0301sed", + ], +) +def test_changelog_parser_allows_non_unreleased_renderings(example: str) -> None: + text = _history("## [0.1.3] - 2026-07-30") + "\n" + example + "\n" + + assert changelog_release_dates(text) == {"0.1.3": "2026-07-30"} + + @pytest.mark.parametrize( ("headings", "message"), [ @@ -188,4 +282,4 @@ def test_changelog_parser_ignores_nonprose_heading_examples(example: str) -> Non def test_repository_changelog_is_canonical() -> None: dates = changelog_release_dates(Path("CHANGELOG.md").read_text(encoding="utf-8")) - assert dates["0.1.3"] == "2026-07-30" + assert next(iter(dates)) == read_project_version() diff --git a/tests/test_release_documents.py b/tests/test_release_documents.py index 092580f..c2a352f 100644 --- a/tests/test_release_documents.py +++ b/tests/test_release_documents.py @@ -526,6 +526,42 @@ def test_release_evidence_block_rejects_unrelated_exact_version( "- Publish workflow " "https://github.com/cometapi-dev/cometapi-python/actions/runs/99999999999", ), + ( + "release workflow run", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246\n" + "- Release run: [details](https://github.com/cometapi-dev/cometapi-python/" + "actions/runs/99999999999)", + ), + ( + "release workflow run", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246\n" + "- **Release run:** [Actions](https://github.com/cometapi-dev/cometapi-python/" + "actions/runs/99999999999)", + ), + ( + "release workflow run", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246\n" + "- Release Actions run https://github.com/cometapi-dev/cometapi-python/actions/" + "runs/99999999999", + ), + ( + "release workflow run", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246\n" + "- [details](https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "99999999999) is the release run", + ), ( "wheel SHA256", "- Wheel SHA256: 3f12c26ae1ae7a1de5ac19d8ef27a784b2bf592143c716493f1b0f35ec19daca", @@ -598,6 +634,112 @@ def test_release_evidence_rejects_contradictory_labeled_identity_values( assert "contradict" in str(caught.value) +def test_release_evidence_rejects_ancillary_workflow_run( + releasable_documents: Path, +) -> None: + evidence = _release_evidence_block().replace( + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246\n" + "- Required CI https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30511373822", + 1, + ) + for name in ("ROADMAP.md", "RELEASING.md"): + with (releasable_documents / name).open("a", encoding="utf-8") as stream: + stream.write(evidence) + _replace( + releasable_documents, + "CHANGELOG.md", + "# Changelog\n", + "# Changelog\n\n## [0.1.2] - 2026-07-30\n\nHistory.\n", + ) + + with pytest.raises(CheckError) as caught: + require_public_preview_docs() + + message = str(caught.value) + assert "workflow run" in message + assert "contradicts its release-identity marker" in message + assert "outside the immutable evidence block" in message + + +@pytest.mark.parametrize( + "reference", + [ + "", + "", + "", + ], +) +def test_release_evidence_rejects_obsolete_workflow_reference_marker( + releasable_documents: Path, + reference: str, +) -> None: + evidence = _release_evidence_block().replace( + EVIDENCE_IDENTITY, + EVIDENCE_IDENTITY + "\n" + reference, + 1, + ) + for name in ("ROADMAP.md", "RELEASING.md"): + with (releasable_documents / name).open("a", encoding="utf-8") as stream: + stream.write(evidence) + + with pytest.raises(CheckError) as caught: + require_public_preview_docs() + + assert "obsolete workflow-reference marker" in str(caught.value) + assert "outside the immutable evidence block" in str(caught.value) + + +@pytest.mark.parametrize( + "url", + [ + "https://evil.example/?next=https://github.com/cometapi-dev/cometapi-python/" + "actions/runs/30511373822", + "https://evil.example/#https://github.com/cometapi-dev/cometapi-python/" + "actions/runs/30511373822", + "http://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "https://evil.example/?next=https%3A%2F%2Fgithub.com%2Fcometapi-dev%2F" + "cometapi-python%2Factions%2Fruns%2F30511373822", + "//github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "/cometapi-dev/cometapi-python/actions/runs/30511373822", + "//github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "mailto:https://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "prefixhttps://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "https://github.com/other/repository/actions/runs/30511373822", + "https://github.com/CometAPI-dev/cometapi-python/actions/runs/30511373822", + "https://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822", + "https://github.com/cometapi-dev/cometapi-python/actions\\/runs\\/30511373822", + "https://github.com/cometapi-dev/cometapi-python/actions\\runs\\30511373822", + "https://github.com/cometapi-dev/cometapi-python\actions\runs\30511373822", + "https://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822.evil", + "https://github.com/cometapi-dev/cometapi-python/actions/runs/30511373822/attempts/0", + ], +) +def test_release_evidence_rejects_noncanonical_workflow_reference_url( + releasable_documents: Path, + url: str, +) -> None: + evidence = _release_evidence_block().replace( + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + "30515861246", + "- Release workflow https://github.com/cometapi-dev/cometapi-python/actions/runs/" + f"30515861246\n- Required CI {url}", + 1, + ) + for name in ("ROADMAP.md", "RELEASING.md"): + with (releasable_documents / name).open("a", encoding="utf-8") as stream: + stream.write(evidence) + + with pytest.raises(CheckError) as caught: + require_public_preview_docs() + + assert "non-canonical Actions URL" in str(caught.value) + assert "/actions/runs/" in str(caught.value) + + def test_fenced_release_evidence_is_not_accepted_as_history( releasable_documents: Path, ) -> None: @@ -793,7 +935,10 @@ def test_releasable_cli_allows_next_patch_without_guidance_edits( changelog.write_text( changelog.read_text(encoding="utf-8").replace( "# Changelog\n", - "# Changelog\n\n## [0.1.3] - 2026-07-30\n\nPatch maintenance.\n", + "# Changelog\n\n" + "## [0.1.3](https://github.com/cometapi-dev/cometapi-python/compare/" + "v0.1.0-alpha.1%2Brecovery.1...v0.1.3) (2026-07-30)\n\n" + "Patch maintenance.\n", 1, ), encoding="utf-8", @@ -828,7 +973,9 @@ def test_releasable_cli_allows_next_patch_without_guidance_edits( changelog.write_text( changelog.read_text(encoding="utf-8").replace( "# Changelog\n", - "# Changelog\n\n## [0.1.4] - 2026-08-01\n\nPatch maintenance.\n", + "# Changelog\n\n" + "## [0.1.4](https://github.com/cometapi-dev/cometapi-python/compare/" + "v0.1.3...v0.1.4) (2026-08-01)\n\nPatch maintenance.\n", 1, ), encoding="utf-8", @@ -849,6 +996,35 @@ def test_releasable_cli_allows_next_patch_without_guidance_edits( } == durable_before +def test_releasable_cli_rejects_unmanaged_unreleased_heading( + releasable_documents: Path, +) -> None: + version_script = _copy_version_checker(releasable_documents) + changelog = releasable_documents / "CHANGELOG.md" + changelog.write_text( + changelog.read_text(encoding="utf-8").replace( + "# Changelog\n", + "# Changelog\n\n## [Unreleased]\n", + 1, + ), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(version_script), "--require-public-preview-docs"], + cwd=releasable_documents, + text=True, + check=False, + capture_output=True, + ) + + assert result.returncode != 0 + assert "CHANGELOG.md:" in result.stderr + assert "unmanaged Unreleased heading is forbidden" in result.stderr + assert "remove it" in result.stderr + assert "Release Please" in result.stderr + + def test_releasable_docs_accept_release_please_native_heading( releasable_documents: Path, ) -> None: @@ -1213,8 +1389,8 @@ def test_sdist_rejects_cross_document_release_evidence_mismatch(tmp_path: Path) releasing = root / "RELEASING.md" releasing.write_text( releasing.read_text(encoding="utf-8").replace( - "`45429f373bbd11314ec43ba81904fdbb78db2522`. The release commit passed", - "`aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`. The release commit passed", + "[release run 30550536000]", + "release commit `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`; [release run 30550536000]", 1, ), encoding="utf-8", diff --git a/uv.lock b/uv.lock index 5ce4bf0..fec2b94 100644 --- a/uv.lock +++ b/uv.lock @@ -231,6 +231,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "httpx" }, + { name = "markdown-it-py" }, { name = "packaging" }, { name = "pyright" }, { name = "pytest" }, @@ -249,6 +250,7 @@ requires-dist = [{ name = "openai", specifier = ">=2.45.0,<3.0.0" }] dev = [ { name = "build", specifier = ">=1.3.0,<2.0.0" }, { name = "httpx", specifier = ">=0.28.1,<1.0.0" }, + { name = "markdown-it-py", specifier = ">=4.0.0,<5.0.0" }, { name = "packaging", specifier = ">=24.2,<27.0" }, { name = "pyright", specifier = ">=1.1.408,<2.0.0" }, { name = "pytest", specifier = ">=8.3.5,<10.0.0" },