From c97d017845ca3b9e8dc30851aecc02c9190faacd Mon Sep 17 00:00:00 2001 From: CometAPI Date: Fri, 31 Jul 2026 16:49:05 +0800 Subject: [PATCH 1/3] fix: make release changelog ownership deterministic --- AGENTS.md | 10 ++ ARCHITECTURE.md | 19 ++-- CHANGELOG.md | 2 - CONTRIBUTING.md | 4 +- RELEASING.md | 36 +++++++ ROADMAP.md | 28 ++++++ scripts/_checks.py | 85 ++++++++++++---- scripts/check_version.py | 75 ++++++-------- tests/test_changelog_gate.py | 73 +++++++++++--- tests/test_release_documents.py | 173 +++++++++++++++++++++++++++++++- 10 files changed, 414 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe50c53..fd01168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,16 @@ 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. Every ancillary Actions run URL + in that block requires an exact workflow-reference marker. The document gate + must reject non-canonical URLs and undeclared, unused, malformed, duplicate, + or contradictory run identities regardless of prose or Markdown labeling. ## Repository independence diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 806b951..9095f1b 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,12 @@ 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 the canonical publication run through its machine-readable identity +and binds every ancillary workflow URL through an exact reference marker. The +checker requires a canonical URL and rejects undeclared or unused run identities, +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..ed87f4f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -198,6 +198,20 @@ 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 workflow history bound by machine-readable markers. The canonical +publication run is part of the identity; every ancillary implementation CI, +Release Please, failed-publication, or recovery URL requires an exact workflow +reference marker. The document gate rejects non-canonical URLs and undeclared, +unused, malformed, duplicate, or contradictory run identities 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 @@ -473,6 +487,9 @@ authorized. + + + - 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 @@ -508,6 +525,12 @@ authorized. + + + + + + - 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) @@ -557,6 +580,13 @@ authorized. + + + + + + + - Metadata and runtime [PR #29](https://github.com/cometapi-dev/cometapi-python/pull/29) made packaged long descriptions release-neutral, added artifact assertions, @@ -612,6 +642,12 @@ authorized. + + + + + + - 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 diff --git a/ROADMAP.md b/ROADMAP.md index 165a002..316e01f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -416,6 +416,8 @@ Accepted release evidence: + + - Metadata fix [PR #16](https://github.com/cometapi-dev/cometapi-python/pull/16) merged as `6344c2d0e2e975360b42c887275c1950b82918ee`; recovery contract @@ -449,6 +451,12 @@ Accepted release evidence: + + + + + + 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. @@ -529,6 +537,12 @@ the only remaining release-related repository variable. + + + + + + Maintenance release `0.1.1` rejects explicitly blank API keys and base URLs, treats a blank environment key as missing, and uses the default CometAPI URL @@ -591,6 +605,13 @@ variables are absent. No recovery tag or recovery workflow was used. + + + + + + + [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 @@ -665,6 +686,13 @@ used for `0.1.2`. + + + + + + + [Implementation PR #34](https://github.com/cometapi-dev/cometapi-python/pull/34) removed the mutable published-patch claim from persistent guidance and extended diff --git a/scripts/_checks.py b/scripts/_checks.py index 8a79a66..6909885 100644 --- a/scripts/_checks.py +++ b/scripts/_checks.py @@ -81,6 +81,13 @@ r"wheel-sha256=(?P[0-9a-f]{64}) " r"sdist-sha256=(?P[0-9a-f]{64}) -->$" ) +RELEASE_EVIDENCE_WORKFLOW_REFERENCE = re.compile( + r"^$" +) +_ANY_RELEASE_EVIDENCE_WORKFLOW_REFERENCE = re.compile( + r"(?m)^.*cometapi-release-workflow-reference.*$" +) _ANY_RELEASE_EVIDENCE_IDENTITY = re.compile(r"(?m)^.*cometapi-release-identity.*$") _ANY_RELEASE_EVIDENCE_MARKER = re.compile(r"(?m)^.*cometapi-release-evidence:.*$") _EXACT_VERSION = ( @@ -131,7 +138,8 @@ _RECOVERY_TAGS = {"0.1.0a1": "v0.1.0-alpha.1+recovery.1"} _FULL_COMMIT = re.compile(r"(?])" ) _WHEEL_DIGEST = re.compile( r"\bwheel\s+sha256\b[^0-9a-f]{0,96}(?P[0-9a-f]{64})(?![0-9a-f])", @@ -504,6 +512,39 @@ def _identity_violations( sdist_sha256=match.group("sdist"), ) findings: list[tuple[int, str]] = [] + workflow_references: set[str] = set() + workflow_reference_lines = [ + value for value in nonempty if "cometapi-release-workflow-reference" in value + ] + for reference_line in _ANY_RELEASE_EVIDENCE_WORKFLOW_REFERENCE.findall(body): + if reference_line.strip() not in workflow_reference_lines: + findings.append( + (line, f"release-evidence block for {version} has malformed workflow reference") + ) + for reference_line in workflow_reference_lines: + reference = RELEASE_EVIDENCE_WORKFLOW_REFERENCE.fullmatch(reference_line) + if reference is None: + findings.append( + (line, f"release-evidence block for {version} has malformed workflow reference") + ) + continue + run = reference.group("run") + if run in workflow_references: + findings.append( + ( + line, + f"release-evidence block for {version} duplicates workflow reference run {run}", + ) + ) + workflow_references.add(run) + if identity.workflow_run in workflow_references: + findings.append( + ( + line, + f"release-evidence block for {version} must not classify its canonical " + "publication run as an ancillary workflow reference", + ) + ) expected_tag = _canonical_release_tag(version) if identity.tag != expected_tag: findings.append( @@ -569,26 +610,32 @@ 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]) + # Prose may cite only the canonical publication run or an exact ancillary run + # declared by a typed machine-readable reference. + release_run_values = { + run.group(0).split("/actions/runs/", 1)[1].split("/", 1)[0] + for run in _ACTIONS_RUN.finditer(prose) + } + unreferenced_workflow_runs = set(workflow_references) - release_run_values + if unreferenced_workflow_runs: + findings.append( + ( + line, + f"release-evidence block for {version} declares ancillary workflow runs " + "that have no exact canonical Actions URL in the block", + ) + ) + undeclared_release_runs = release_run_values - { + identity.workflow_run, + *workflow_references, + } labeled_values = ( ("release commit", release_commit_values, identity.commit), - ("release workflow run", release_run_values, identity.workflow_run), + ( + "release workflow run", + undeclared_release_runs | {identity.workflow_run}, + identity.workflow_run, + ), ( "wheel SHA256", { diff --git a/scripts/check_version.py b/scripts/check_version.py index 53d1e77..53b6680 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import html import re +import unicodedata from dataclasses import dataclass from datetime import date from itertools import pairwise @@ -76,11 +78,7 @@ 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_LEVEL_TWO_HEADING = re.compile(r"^ {0,3}##(?!#)[ \t]+(?P