Skip to content

Commit 2ee1f65

Browse files
author
CometAPI
committed
fix: reject ambiguous release evidence
1 parent 28fee2b commit 2ee1f65

8 files changed

Lines changed: 222 additions & 87 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,9 @@ Post-stable invariants:
161161
parsing is renderer-dependent and must fail the version gate before merge or
162162
release. The same gate must reject any `Unreleased` level-two heading.
163163
13. A validated release-evidence block binds one canonical publication workflow
164-
run to its machine-readable identity marker and may contain no other Actions
165-
run URL. Keep preparatory implementation, CI, Release Please, failed-attempt,
164+
run URL without an attempt suffix to its machine-readable identity marker and
165+
may contain no other Actions run URL. Record attempt provenance as plain
166+
text. Keep preparatory implementation, CI, Release Please, failed-attempt,
166167
and recovery history outside that block. The document gate must reject
167168
non-canonical, wrapped, encoded, control-obfuscated, malformed, or
168169
contradictory workflow URLs regardless of prose or Markdown labeling, and

ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,9 @@ ambiguous external state and is forbidden.
168168
Immutable run, tag, commit, registry, and digest records live only in the
169169
validated release-evidence blocks in `ROADMAP.md` and `RELEASING.md`. Each
170170
block binds only the canonical publication run through its machine-readable
171-
identity; preparatory implementation, CI, Release Please, failed-attempt, and
172-
recovery history stays outside the block. The checker rejects every other run
171+
identity, without an attempt suffix; attempt provenance remains plain text.
172+
Preparatory implementation, CI, Release Please, failed-attempt, and recovery
173+
history stays outside the block. The checker rejects every other run
173174
identity and binds every source occurrence to exactly one rendered Markdown or
174175
HTML navigation destination after bounded normalization. Wrapped, encoded,
175176
control-obfuscated, malformed, or contradictory Actions URLs fail closed, so

RELEASING.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,13 @@ valid on every patch release. Use only canonical Markdown for level-two
206206
headings; raw HTML H2 semantics vary by renderer and therefore fail closed.
207207

208208
Each validated release-evidence block contains the immutable release identity
209-
and only its canonical publication workflow. Preparatory implementation CI,
210-
Release Please, failed-publication, and recovery history remains outside the
211-
block. The document gate binds every source occurrence to exactly one rendered
212-
navigation destination and rejects every different run identity plus wrapped,
213-
encoded, control-obfuscated, or malformed Actions URLs regardless of prose or
214-
Markdown labeling.
209+
and only its canonical publication workflow URL without an attempt suffix.
210+
Record attempt provenance as plain text. Preparatory implementation CI, Release
211+
Please, failed-publication, and recovery history remains outside the block. The
212+
document gate binds every source occurrence to exactly one rendered navigation
213+
destination and rejects every different run identity plus wrapped, encoded,
214+
control-obfuscated, or malformed Actions URLs regardless of prose or Markdown
215+
labeling.
215216

216217
Release mode (`check_version.py --require-releasable-docs`) also fails closed
217218
until project authorship, the canonical GitHub repository URL, the copyright
@@ -562,8 +563,8 @@ authorized.
562563
`64c7cb87745032703b3374cc562ea00b979416c54908862dbcebd116b2dc44c8`.
563564
Both files match the retained pre-publication digest record. PyPI Integrity
564565
API provenance identifies repository `cometapi-dev/cometapi-python`, workflow
565-
`publish.yml`, environment `pypi`, release commit `576e7503`, and release
566-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821548/attempts/1).
566+
`publish.yml`, environment `pypi`, release commit `576e7503`, and release run
567+
attempt 1.
567568
- An independent post-workflow verification downloaded both public files,
568569
verified both provenance records with `pypi-attestations==0.0.29`, installed
569570
`cometapi==0.1.1` from `https://pypi.org/simple/`, verified the public version
@@ -618,8 +619,8 @@ authorized.
618619
`21c8edc0586610de1a9a8cd39b54ed23d2b1e20552100f69f53938cb7678da3d`.
619620
Both files match the retained pre-publication digest record. PyPI Integrity
620621
API provenance identifies repository `cometapi-dev/cometapi-python`, workflow
621-
`publish.yml`, environment `pypi`, release commit `710c5649`, and release
622-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861246/attempts/1).
622+
`publish.yml`, environment `pypi`, release commit `710c5649`, and release run
623+
attempt 1.
623624
- An independent post-workflow verification downloaded both public files,
624625
verified both provenance records with `pypi-attestations==0.0.29`, installed
625626
`cometapi==0.1.2` from `https://pypi.org/simple/`, verified the public version
@@ -676,8 +677,8 @@ authorized.
676677
`07ded54606d50f44b689dad38cf93a74e1175370efaa33be84a3c01240d48e66`.
677678
Both files match the retained pre-publication digest record. PyPI Integrity
678679
API provenance identifies repository `cometapi-dev/cometapi-python`, workflow
679-
`publish.yml`, environment `pypi`, release commit `45429f37`, and release
680-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550536000/attempts/1).
680+
`publish.yml`, environment `pypi`, release commit `45429f37`, and release run
681+
attempt 1.
681682
- Independent post-workflow verification downloaded both public files,
682683
verified both provenance records with `pypi-attestations==0.0.29`, installed
683684
`cometapi==0.1.3` from `https://pypi.org/simple/`, verified the public version

ROADMAP.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -579,8 +579,8 @@ and source-distribution SHA256
579579
`64c7cb87745032703b3374cc562ea00b979416c54908862dbcebd116b2dc44c8`.
580580
Both files matched the retained pre-publication digest record. Their PyPI
581581
Integrity API provenance names repository `cometapi-dev/cometapi-python`,
582-
workflow `publish.yml`, environment `pypi`, release commit `576e7503`, and
583-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30429821548/attempts/1).
582+
workflow `publish.yml`, environment `pypi`, release commit `576e7503`, and run
583+
attempt 1.
584584
An independent post-workflow verification downloaded both public files,
585585
verified their provenance with `pypi-attestations==0.0.29`, installed
586586
`cometapi==0.1.1` from the public simple index, checked version and public
@@ -651,8 +651,8 @@ and source-distribution SHA256
651651
`21c8edc0586610de1a9a8cd39b54ed23d2b1e20552100f69f53938cb7678da3d`.
652652
Both files match the retained pre-publication digest record. Their PyPI
653653
Integrity API provenance names repository `cometapi-dev/cometapi-python`,
654-
workflow `publish.yml`, environment `pypi`, release commit `710c5649`, and
655-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30515861246/attempts/1).
654+
workflow `publish.yml`, environment `pypi`, release commit `710c5649`, and run
655+
attempt 1.
656656
An independent post-workflow verification downloaded both public files,
657657
verified both provenance records with `pypi-attestations==0.0.29`, installed
658658
`cometapi==0.1.2` from the public simple index, verified the public version and
@@ -716,8 +716,7 @@ and source-distribution SHA256
716716
Both public files match the retained pre-publication digest record. PyPI
717717
Integrity API provenance identifies repository
718718
`cometapi-dev/cometapi-python`, workflow `publish.yml`, environment `pypi`,
719-
release commit `45429f37`, and release
720-
[run attempt 1](https://github.com/cometapi-dev/cometapi-python/actions/runs/30550536000/attempts/1).
719+
release commit `45429f37`, and release run attempt 1.
721720
An independent post-workflow verification downloaded both public files,
722721
verified both provenance records with `pypi-attestations==0.0.29`, installed
723722
`cometapi==0.1.3` from `https://pypi.org/simple/`, verified the public version

scripts/_checks.py

Lines changed: 81 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,9 @@ def has_obfuscated_control(value: str) -> bool:
784784
)
785785

786786
direct = unicodedata.normalize("NFKC", body)
787-
variants = [direct]
787+
variants = [body]
788+
if direct != body:
789+
variants.append(direct)
788790
control_obfuscation = has_obfuscated_control(direct)
789791
converged = False
790792
for _ in range(len(direct) + 1):
@@ -805,67 +807,74 @@ def has_obfuscated_control(value: str) -> bool:
805807
break
806808
variants.append(decoded)
807809

808-
normalized = variants[-1]
809-
normalized_paths = list(_ACTIONS_PATH.finditer(normalized))
810810
try:
811811
from markdown_it import MarkdownIt
812812
except ImportError as exc:
813813
raise CheckError("release-evidence Markdown validation requires markdown-it-py") from exc
814814

815-
bindings: list[int] = []
816-
used_sentinels = {path.group("run") for path in normalized_paths}
817-
for index, path in enumerate(normalized_paths):
818-
run_start, run_end = path.span("run")
819-
width = run_end - run_start
820-
sentinel_value = 10 ** (width - 1) + index
821-
sentinel = str(sentinel_value)
822-
while len(sentinel) == width and sentinel in used_sentinels:
823-
sentinel_value += 1
815+
def rendered_bindings(
816+
source: str,
817+
) -> tuple[list[re.Match[str]], list[tuple[str, str | None]], list[int]]:
818+
paths = list(_ACTIONS_PATH.finditer(source))
819+
identities = [(path.group("run"), path.group("attempt")) for path in paths]
820+
bindings: list[int] = []
821+
used_sentinels = {path.group("run") for path in paths}
822+
for index, path in enumerate(paths):
823+
run_start, run_end = path.span("run")
824+
width = run_end - run_start
825+
sentinel_value = 10 ** (width - 1) + index
824826
sentinel = str(sentinel_value)
825-
if len(sentinel) != width:
826-
raise CheckError("too many Actions URL occurrences to bind unambiguously")
827-
used_sentinels.add(sentinel)
828-
mutated = normalized[:run_start] + sentinel + normalized[run_end:]
829-
identity = (sentinel, path.group("attempt") or None)
830-
destinations: Counter[tuple[str, str | None]] = Counter()
831-
parser = MarkdownIt("commonmark", {"html": True})
832-
for token in parser.parse(mutated):
833-
if token.type == "inline":
834-
link_depth = 0
835-
html_parser = _ActionsAnchorParser()
836-
for child in token.children or []:
837-
if child.type == "link_open":
838-
link_depth += 1
839-
target = child.attrGet("href")
840-
if isinstance(target, str):
841-
destination = _CANONICAL_ACTIONS_DESTINATION.fullmatch(target)
842-
if destination is not None:
843-
destinations[
844-
(destination.group("run"), destination.group("attempt"))
845-
] += 1
846-
elif child.type == "link_close":
847-
link_depth = max(0, link_depth - 1)
848-
elif child.type == "html_inline":
849-
html_parser.feed(child.content)
850-
elif child.type == "text" and link_depth == 0 and html_parser.anchor_depth == 0:
851-
destinations.update(_bare_actions_destinations(child.content))
852-
html_parser.close()
853-
destinations.update(html_parser.destinations)
854-
elif token.type == "html_block":
855-
html_parser = _ActionsAnchorParser()
856-
html_parser.feed(token.content)
857-
html_parser.close()
858-
destinations.update(html_parser.destinations)
859-
bindings.append(destinations[identity])
827+
while len(sentinel) == width and sentinel in used_sentinels:
828+
sentinel_value += 1
829+
sentinel = str(sentinel_value)
830+
if len(sentinel) != width:
831+
raise CheckError("too many Actions URL occurrences to bind unambiguously")
832+
used_sentinels.add(sentinel)
833+
mutated = source[:run_start] + sentinel + source[run_end:]
834+
identity = (sentinel, path.group("attempt") or None)
835+
destinations: Counter[tuple[str, str | None]] = Counter()
836+
parser = MarkdownIt("commonmark", {"html": True})
837+
for token in parser.parse(mutated):
838+
if token.type == "inline":
839+
link_depth = 0
840+
html_parser = _ActionsAnchorParser()
841+
for child in token.children or []:
842+
if child.type == "link_open":
843+
link_depth += 1
844+
target = child.attrGet("href")
845+
if isinstance(target, str):
846+
destination = _CANONICAL_ACTIONS_DESTINATION.fullmatch(target)
847+
if destination is not None:
848+
destinations[
849+
(destination.group("run"), destination.group("attempt"))
850+
] += 1
851+
elif child.type == "link_close":
852+
link_depth = max(0, link_depth - 1)
853+
elif child.type == "html_inline":
854+
html_parser.feed(child.content)
855+
elif (
856+
child.type == "text"
857+
and link_depth == 0
858+
and html_parser.anchor_depth == 0
859+
):
860+
destinations.update(_bare_actions_destinations(child.content))
861+
html_parser.close()
862+
destinations.update(html_parser.destinations)
863+
elif token.type == "html_block":
864+
html_parser = _ActionsAnchorParser()
865+
html_parser.feed(token.content)
866+
html_parser.close()
867+
destinations.update(html_parser.destinations)
868+
bindings.append(destinations[identity])
869+
return paths, identities, bindings
870+
871+
rendered_variants = [rendered_bindings(variant) for variant in variants]
872+
normalized_paths, normalized_identities, bindings = rendered_variants[-1]
860873

861874
malformed = control_obfuscation or not converged or any(count != 1 for count in bindings)
862-
normalized_identities = [
863-
(match.group("run"), match.group("attempt")) for match in normalized_paths
864-
]
865875
malformed = malformed or any(
866-
[(match.group("run"), match.group("attempt")) for match in _ACTIONS_PATH.finditer(variant)]
867-
!= normalized_identities
868-
for variant in variants[:-1]
876+
identities != normalized_identities or variant_bindings != bindings
877+
for _, identities, variant_bindings in rendered_variants[:-1]
869878
)
870879

871880
if malformed:
@@ -874,8 +883,7 @@ def has_obfuscated_control(value: str) -> bool:
874883
line,
875884
f"release-evidence block for {version} contains a non-canonical Actions URL; "
876885
"use the exact repository /actions/runs/<positive-id> URL as plain Markdown "
877-
"text, an autolink, or a link destination, with an optional "
878-
"/attempts/<positive-id> suffix",
886+
"text, an autolink, or a link destination without an attempt suffix",
879887
)
880888
)
881889
run_values = {match.group("run") for match in normalized_paths}
@@ -888,10 +896,24 @@ def has_obfuscated_control(value: str) -> bool:
888896
"outside the immutable evidence block",
889897
)
890898
)
891-
canonical_workflow_count = sum(
892-
count == 1 and path.group("run") == expected_run
899+
matching_base_count = sum(
900+
count == 1 and path.group("run") == expected_run and path.group("attempt") is None
893901
for path, count in zip(normalized_paths, bindings, strict=True)
894902
)
903+
canonical_workflow_count = (
904+
1
905+
if normalized_identities == [(expected_run, None)] and bindings == [1] and not malformed
906+
else 0
907+
)
908+
if len(normalized_paths) != 1 or matching_base_count != 1:
909+
findings.append(
910+
(
911+
line,
912+
f"release-evidence block for {version} must contain exactly one canonical "
913+
"release workflow URL without an attempt suffix and no other Actions URL; "
914+
"record the attempt number as plain provenance text",
915+
)
916+
)
895917
return findings, canonical_workflow_count
896918

897919

@@ -1049,10 +1071,9 @@ def exact_release_version_violations(
10491071
if document == "CHANGELOG.md":
10501072
return []
10511073
if document in RELEASE_EVIDENCE_DOCUMENTS:
1052-
structural = unicodedata.normalize("NFKC", text)
10531074
searchable, marker_findings, _ = _evidence_block_violations(
10541075
document,
1055-
structural,
1076+
text,
10561077
)
10571078
normalized_searchable = _normalized_document_text(searchable)
10581079
searchable = normalized_searchable
@@ -1074,8 +1095,7 @@ def release_evidence_identities(
10741095
"""Return internally validated immutable identities from an evidence document."""
10751096
if document not in RELEASE_EVIDENCE_DOCUMENTS:
10761097
return {}
1077-
structural = unicodedata.normalize("NFKC", text)
1078-
_, findings, identities = _evidence_block_violations(document, structural)
1098+
_, findings, identities = _evidence_block_violations(document, text)
10791099
if findings:
10801100
rendered = "; ".join(f"{document}:{line}: {label}" for line, label in findings)
10811101
raise CheckError(rendered)

scripts/check_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def _raw_h2_heading_lines(text: str) -> list[int]:
246246
)
247247
for candidate in candidates:
248248
visible_html = re.sub(r"<!--.*?-->", "", candidate.content, flags=re.DOTALL)
249-
for match in re.finditer(r"(?i)<[ \t\r\n]*h2(?=[ \t\r\n>/])", visible_html):
249+
for match in re.finditer(r"(?i)<[ \t\r\n\f]*h2(?=[ \t\r\n\f>/])", visible_html):
250250
relative_line = visible_html.count("\n", 0, match.start())
251251
lines.add(token.map[0] + relative_line + 1)
252252
return sorted(lines)

tests/test_changelog_gate.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ def test_changelog_parser_allows_non_unreleased_renderings(example: str) -> None
182182
"<h2>Archived release</h2>",
183183
'<H2 class="history">Archived release</H2>',
184184
"Intro <h2>Archived release</h2>",
185+
"<h2\f>Archived release</h2>",
186+
"<h2\fclass=history>Archived release</h2>",
185187
],
186188
)
187189
def test_changelog_parser_rejects_all_raw_html_level_two_headings(heading: str) -> None:

0 commit comments

Comments
 (0)