From d93a741cd573800bd258ff85dc4f5e22f10bde03 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 10:59:00 -0600 Subject: [PATCH 1/7] Pin rel-1.16.0's README.env link to a commit, not main (#70) The [readme-env] reference definition pointed at blob/main/README.env, which drifts as main moves. It cannot point at blob/rel-1.16.0/ as data-platform#98 proposes: README.env was added in 700d2ed (#56), after the tag, with the backported verification section that links it, so that URL 404s. Pin it to 700d2ed instead, whose README.env is identical to main's today. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- doc/release-notes/rel-1.16.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release-notes/rel-1.16.0.md b/doc/release-notes/rel-1.16.0.md index e26e2b9..b7e0331 100644 --- a/doc/release-notes/rel-1.16.0.md +++ b/doc/release-notes/rel-1.16.0.md @@ -407,7 +407,7 @@ in fact been a no-op since before `rel-1.15.0`. [plan-40]: https://github.com/osprey-dcs/dp-python-lib/blob/rel-1.16.0/plan/tickets/40/plan.md [plan-41]: https://github.com/osprey-dcs/dp-python-lib/blob/rel-1.16.0/plan/tickets/41/plan.md [plan-readme]: https://github.com/osprey-dcs/dp-python-lib/blob/rel-1.16.0/plan/README.md -[readme-env]: https://github.com/osprey-dcs/dp-python-lib/blob/main/README.env +[readme-env]: https://github.com/osprey-dcs/dp-python-lib/blob/700d2edf44776ad86ee220836db1facf340d4629/README.env ## Verifying these artifacts From 5c0a6d7cec8659f9aa0d623d6e3fa0908dbd2fe1 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 10:59:00 -0600 Subject: [PATCH 2/7] Release notes checker: link and placeholder rules from data-platform#98 (#70) Adds R1-R5 for rel-*.md and N1-N3 for NEXT.md: no relative links; osprey-dcs blob/tree/raw links pinned to the file's own tag (main in NEXT.md); paths and anchors into this repo checked against the working tree, with no anchor onto a duplicated heading; no leftover rel-//. Reference definitions and bare URLs are covered, and code spans and fenced blocks are stripped for the link rules but not for R5. Every rel-*.md except the highest version counts as already released and gets the form rules only (R1, R2, R5). R2 also accepts a full 40-character commit SHA, for a target that did not exist at the tag, as with rel-1.16.0's README.env link. The script is now meant to be copied verbatim into the other four repos: everything repo-specific is in a marked block at the top, and the cosign identity rules for dp-service, dp-desktop-app, and dp-grpc are implemented behind it and self-tested under their own configurations in every copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- .dev/tools/check-release-notes.py | 881 ++++++++++++++++++++++++++---- 1 file changed, 772 insertions(+), 109 deletions(-) diff --git a/.dev/tools/check-release-notes.py b/.dev/tools/check-release-notes.py index bf59223..68450e4 100755 --- a/.dev/tools/check-release-notes.py +++ b/.dev/tools/check-release-notes.py @@ -1,67 +1,415 @@ #!/usr/bin/env python3 -"""Verify that every doc/release-notes/rel-X.Y.Z.md carries a correct verification section and changelog link, -and that the NEXT.md draft carries neither. - -The notes file is published verbatim as the GitHub release body (release.yml; plan/tickets/56/plan.md D1), so -the artifact verification instructions and the changelog link exist only if the file contains them. This checks, -per file: - - - the file name is rel-X.Y.Z.md (release.yml derives the name from the tag, so nothing else is ever published); - - there is a `## Verifying these artifacts` heading; - - there is at least one `sigstore verify identity` command, each naming the wheel, the sdist, and SHA256SUMS, - and every `--cert-identity` in the file is exactly this repository's release.yml at *this file's* tag, with - the GitHub Actions OIDC issuer; - - there is a `**Full Changelog**:` compare link ending at this file's tag and starting at an earlier one. - -doc/release-notes/NEXT.md is the version-less draft that accumulates during a release cycle and is renamed to -rel-.md at the cut (#58). Every part checked above names the release's tag, so in NEXT.md each one is a -guess at a version not yet decided; there the check is inverted, and a verification heading, a `sigstore verify -identity` command, a `--cert-identity`, or a Full Changelog line is an error. Only the real thing counts: a -heading or command at the start of a line, not the draft's own checklist mentioning them in prose. - -The identity check is the one that earns its keep. The section is hand-written per release, usually by copying -the previous one, and a stale tag in the identity makes `sigstore verify` reject every genuine artifact of the -release ("Certificate's SANs do not match"). Readers would reasonably conclude the release is forged. The -changelog link is copied the same way and goes stale the same way, though less dangerously. Only the `` -end can be checked: which release came before is not knowable from one file, so `` is checked only for -being earlier. - -Runs in CI's quality job over every notes file, so a mistake fails the notes PR rather than the tag push; and in -release.yml on the tagged file, as a backstop. Each run starts with a self-test that feeds the rules known-bad -notes and fails if any is accepted, so a rule that has quietly stopped matching cannot pass as clean notes. +"""Check doc/release-notes/rel-X.Y.Z.md and the NEXT.md draft: their links, their placeholders, and the +tag-bearing parts of their verification instructions. + +One script, copied verbatim into each of the five osprey-dcs repos (dp-grpc, dp-service, dp-desktop-app, +dp-python-lib, data-platform). The copies differ ONLY in the "REPO-SPECIFIC CONFIGURATION" block below. The rules +are osprey-dcs/data-platform#98. The self-test exercises every rule, including identity rules a given copy does +not enable, so a copy whose rules have stopped matching fails on its first run. + +A notes file is published verbatim as its GitHub release body (here via release.yml's `body_path`; +plan/tickets/56/plan.md), so whatever the file says is what readers of the release page get. GitHub does not +resolve relative links in a release body, a link pinned to `main` drifts as the repo moves on, and a link copied +from the previous release's notes resolves to real but stale content. None of that looks wrong in a diff or a +local preview, which is why it is checked rather than left to review. + +LINK RULES. Code spans and fenced code blocks are blanked out first, so text that *quotes* a bad link does not +fail; R5 runs on the raw text, because the placeholders it catches live in code blocks. A link is an inline +`[text](target)` or image, a reference definition `[label]: target`, an autolink, or a bare URL. + + rel-X.Y.Z.md + R1 No relative cross-file links: an inline or reference-definition target that is not http(s):, mailto: or + `#anchor` fails (`../../README.md`, `doc/x.md`). + R2 Every github.com/osprey-dcs//(blob|tree)//... and raw.githubusercontent.com/osprey-dcs// + /... link, into any of the five repos, has equal to this file's tag (they release in lockstep). + Exception: a full 40-character commit SHA, for a target that did not exist at the tag -- a section added + to the notes after the release, such as rel-1.16.0's README.env link. A SHA cannot drift; `main`, a short + SHA, or any other tag still fails. + R3 For tag-pinned links into this repo, the path exists in the working tree, spelled exactly (GitHub is + case-sensitive; macOS is not), as a file for blob/raw and a directory for tree. + R4 For tag-pinned links into this repo's .md files with a `#anchor`, and for same-document `#anchor` links, + the anchor is a heading slug (or an ``/`id`) in the target, and that heading is not duplicated + there. A duplicated heading is what silently moves an anchor to `-1`. A duplicate no link points at is + left alone, since there is nothing for it to move. + R5 No template placeholder left: `rel-`, ``, ``. + + NEXT.md (the version-less draft, renamed to rel-.md at the cut) + N1 As R1. + N2 Every osprey-dcs blob/tree/raw link uses `main`: a `rel-*` ref here guesses an undecided version, and R2 + requires repointing at the cut anyway. + N3 R3 and R4 for `main`-pinned links into this repo, against the working tree. This is the check that fails + a PR renaming a heading a NEXT.md link points at, in the PR that renames it. + R5 does not apply to NEXT.md: placeholders belong in a draft. + +R3, R4 and N3 run against the working tree, which is the tree being tagged both in CI on the cut PR and in +release.yml at the tag: no git, no tag peeling, no network. Cross-repo paths and anchors get R2 only, since that +tree is not checked out, and so do SHA-pinned links. Whether URLs resolve over the network is deliberately not +checked: before the tag is pushed, every correctly pinned link 404s. + +WHICH NOTES ARE ALREADY RELEASED. The rel-*.md with the highest X.Y.Z in its directory is the release being cut +(or, between cuts, the latest one) and gets every rule. Every other rel-*.md is already released and gets the +form rules only -- R1, R2, R5 and the identity rules -- because it is immutable and pinned to its own tag, and +checking its paths and anchors against today's tree would fail an old file the first time a heading is renamed on +`main`. This needs only the directory listing, so it works offline and the same way in CI and in release.yml +(which passes just the file being released; the comparison is still against that file's directory). Accepted +consequences: between cuts, the latest release's links into this repo are checked against the moving tree, so a +rename that breaks one fails its PR; and a patch release of an older line would count as released, which the five +repos' lockstep versioning does not produce. + +IDENTITY RULES (rel-X.Y.Z.md only), switched on by the configuration block: + + SIGSTORE_PYTHON_RULES (dp-python-lib) + A `## Verifying these artifacts` heading; at least one `sigstore verify identity`, each naming the wheel, + the sdist and SHA256SUMS; every `--cert-identity` exactly this repository's release.yml at this file's tag, + with the GitHub Actions OIDC issuer; and a `**Full Changelog**:` compare link ending at this file's tag and + starting at an earlier one (which release came before is not knowable from one file). For NEXT.md this is + inverted: a verification heading, a verify command at the start of a line, a `--cert-identity` with a value, + or a Full Changelog line is an error there, since each names the tag. Prose naming them is fine. + COSIGN_IDENTITY_WORKFLOWS (dp-service, dp-desktop-app) + Every `--certificate-identity` is exactly https://github.com//.github/workflows/ + @refs/tags/. + COSIGN_IMAGE (dp-service) + Every `:rel-...` reference names this file's tag. + COSIGN_IDENTITY_REGEXP (dp-grpc) + Every `--certificate-identity-regexp` is exactly this regexp. It has no tag in it by design; the check stops + a later edit from loosening it. + With any COSIGN_* rule on, every `--certificate-oidc-issuer` must also be the GitHub Actions issuer. + +The identity rules earn their keep the same way the link rules do: the verification section is copied from the +previous release, and a stale tag in the identity makes verification reject every genuine artifact. + +Runs in CI on every PR over every notes file, and in release.yml on the tagged file as a backstop. Each run starts +with a self-test that feeds every rule known-bad input it must reject and known-good input it must accept, so a +rule that has quietly stopped matching fails loudly instead of passing everything. Usage: python .dev/tools/check-release-notes.py [FILE ...] -With no arguments, checks every doc/release-notes/rel-*.md, and NEXT.md if present. Stdlib only. Exits 0 if all -pass, 1 otherwise. +With no arguments, checks every /rel-*.md, and NEXT.md if present. Stdlib only. Exits 0 if all pass, +1 otherwise. """ from __future__ import annotations +import os import re import sys +import tempfile +from dataclasses import dataclass, replace from pathlib import Path +from urllib.parse import unquote + +# dp-grpc's documented cosign identity regexp: anchored, and pinned to its repository, release.yml, and a `rel-` tag. +# Shared by every copy (the self-test uses it), and defined here so dp-grpc's configuration below can name it. +DP_GRPC_IDENTITY_REGEXP = r"^https://github.com/osprey-dcs/dp-grpc/\.github/workflows/release\.yml@refs/tags/rel-" + +# ================================================================================================================== +# REPO-SPECIFIC CONFIGURATION -- the only lines that differ between the five copies of this script. +# +# REPOSITORY is "osprey-dcs/" in each, and NOTES_DIR_IN_REPO is the same in all five. Then: +# dp-python-lib SIGSTORE_PYTHON_RULES = True (and no COSIGN_* rules) +# dp-service COSIGN_IDENTITY_WORKFLOWS = ("release.yml", "release-image.yml") +# COSIGN_IMAGE = "ghcr.io/osprey-dcs/dp-service" +# dp-desktop-app COSIGN_IDENTITY_WORKFLOWS = ("release.yml",) +# dp-grpc COSIGN_IDENTITY_REGEXP = DP_GRPC_IDENTITY_REGEXP +# data-platform none of them +# ================================================================================================================== +REPOSITORY = "osprey-dcs/dp-python-lib" +NOTES_DIR_IN_REPO = "doc/release-notes" +SIGSTORE_PYTHON_RULES = True +COSIGN_IDENTITY_WORKFLOWS: tuple[str, ...] = () +COSIGN_IMAGE: str | None = None +COSIGN_IDENTITY_REGEXP: str | None = None +# ================================================================================================================== +# END OF REPO-SPECIFIC CONFIGURATION. Everything below is identical in every copy. +# ================================================================================================================== REPO_ROOT = Path(__file__).resolve().parents[2] -NOTES_DIR = REPO_ROOT / "doc" / "release-notes" +NOTES_DIR = REPO_ROOT / NOTES_DIR_IN_REPO NEXT_NAME = "NEXT.md" - -REPOSITORY = "osprey-dcs/dp-python-lib" +OWNER = "osprey-dcs" OIDC_ISSUER = "https://token.actions.githubusercontent.com" + +@dataclass(frozen=True) +class Config: + repository: str + sigstore_python: bool + cosign_workflows: tuple[str, ...] + cosign_image: str | None + cosign_identity_regexp: str | None + + +CONFIG = Config( + repository=REPOSITORY, + sigstore_python=SIGSTORE_PYTHON_RULES, + cosign_workflows=COSIGN_IDENTITY_WORKFLOWS, + cosign_image=COSIGN_IMAGE, + cosign_identity_regexp=COSIGN_IDENTITY_REGEXP, +) + STEM_RE = re.compile(r"^rel-(\d+)\.(\d+)\.(\d+)$") -HEADING_RE = re.compile(r"^## Verifying these artifacts\s*$", re.MULTILINE) + + +def version_of(tag: str) -> tuple[int, ...]: + match = STEM_RE.match(tag) + assert match is not None + return tuple(int(part) for part in match.groups()) + + +def line_of(text: str, pos: int) -> int: + return text.count("\n", 0, pos) + 1 + + +# ------------------------------------------------------------------------------------------------------------------ +# Which notes are already released +# ------------------------------------------------------------------------------------------------------------------ + + +def newest_release(names: list[str]) -> str | None: + """The stem of the highest-versioned rel-X.Y.Z.md among `names`; every other rel-*.md is already released.""" + tags = [Path(name).stem for name in names if Path(name).suffix == ".md" and STEM_RE.match(Path(name).stem)] + return max(tags, key=version_of, default=None) + + +def is_released(path: Path) -> bool: + newest = newest_release([p.name for p in path.parent.glob("rel-*.md")]) + return newest is not None and version_of(path.stem) < version_of(newest) + + +# ------------------------------------------------------------------------------------------------------------------ +# Markdown: stripping code, collecting links and heading anchors +# ------------------------------------------------------------------------------------------------------------------ + +FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") +FENCE_CLOSE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*$") +# A code span: a run of N backticks, then content with no blank line in it, then a run of exactly N backticks. +CODE_SPAN_RE = re.compile(r"(? str: + """`text` with every fenced code block's lines (fences included) blanked; line numbers are preserved.""" + out: list[str] = [] + fence: str | None = None + for line in text.split("\n"): + if fence is None: + opened = FENCE_OPEN_RE.match(line) + # A backtick fence's info string cannot contain a backtick, so "```x``` y" is a code span, not a fence. + if opened and not (opened.group(1)[0] == "`" and "`" in opened.group(2)): + fence = opened.group(1) + out.append("") + else: + out.append(line) + else: + closed = FENCE_CLOSE_RE.match(line) + if closed and closed.group(1)[0] == fence[0] and len(closed.group(1)) >= len(fence): + fence = None + out.append("") + return "\n".join(out) + + +def strip_code(text: str) -> str: + """`text` with fenced blocks and code spans blanked, keeping every offset's line number.""" + return CODE_SPAN_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), strip_fences(text)) + + +# Inline link or image: `](target`, optionally . Reference definition: `[label]: target` at the start of +# a line (a footnote definition, `[^1]:`, is not a link). +INLINE_TARGET_RE = re.compile(r"\]\(\s*(<[^>\n]*>|[^\s)]+)") +REFDEF_TARGET_RE = re.compile(r"^ {0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(<[^>\n]*>|\S+)", re.MULTILINE) +# Any absolute URL, wherever it appears: GitHub links bare URLs too, so R2 must see them. +URL_RE = re.compile(r"https?://[^\s<>()\[\]\"'`]+") +ALLOWED_TARGET_RE = re.compile(r"^(?:https?:|mailto:|#)", re.IGNORECASE) + + +def link_targets(stripped: str) -> list[tuple[int, str]]: + """(offset, target) for every inline link and reference definition in code-stripped text.""" + found = [(m.start(1), m.group(1)) for m in INLINE_TARGET_RE.finditer(stripped)] + found += [(m.start(1), m.group(1)) for m in REFDEF_TARGET_RE.finditer(stripped)] + return sorted((pos, target.strip("<>")) for pos, target in found) + + +def urls(stripped: str) -> list[tuple[int, str]]: + """(offset, url) for every absolute URL in code-stripped text, without trailing sentence punctuation.""" + return [(m.start(), m.group(0).rstrip(".,;:!?*_")) for m in URL_RE.finditer(stripped)] + + +HEADING_LINE_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$", re.MULTILINE) +HTML_ANCHOR_RE = re.compile(r"]*?\b(?:name|id)\s*=\s*[\"']([^\"']+)[\"']", re.IGNORECASE) +LINE_ANCHOR_RE = re.compile(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") + + +def slug_base(heading: str) -> str: + """GitHub's slug for a heading, before de-duplication: the rendered text lowercased, punctuation dropped, + spaces turned into hyphens. ATX (`#`) headings only; these files use no setext headings.""" + text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", heading) # a link or image renders as its text + text = re.sub(r"<[^>]+>", "", text).replace("`", "") + return re.sub(r"[^\w\- ]", "", text.strip().lower()).replace(" ", "-") + + +def heading_anchors(text: str) -> tuple[set[str], set[str]]: + """(every anchor in a Markdown document, the anchors that belong to a duplicated heading). + + GitHub slugs repeated headings `x`, `x-1`, `x-2`, so a link to any of them is ambiguous: adding or removing one + of those headings silently moves where it lands. + """ + counts: dict[str, int] = {} + anchors: set[str] = set() + by_base: dict[str, list[str]] = {} + for match in HEADING_LINE_RE.finditer(strip_fences(text)): + base = slug_base(match.group(2)) + n = counts.get(base, 0) + counts[base] = n + 1 + slug = base if n == 0 else f"{base}-{n}" + anchors.add(slug) + by_base.setdefault(base, []).append(slug) + duplicated = {slug for slugs in by_base.values() if len(slugs) > 1 for slug in slugs} + anchors.update(HTML_ANCHOR_RE.findall(text)) + return anchors, duplicated + + +# ------------------------------------------------------------------------------------------------------------------ +# osprey-dcs repository links +# ------------------------------------------------------------------------------------------------------------------ + + +@dataclass(frozen=True) +class RepoLink: + repo: str # e.g. "dp-python-lib" + kind: str # "blob", "tree" or "raw" + ref: str + path: str # repository-relative and percent-decoded; "" for the root + anchor: str | None + + +BLOB_RE = re.compile( + rf"^https://(?:www\.)?github\.com/{OWNER}/([\w.-]+)/(blob|tree)/([^/?#]+)(/[^?#]*)?(\?[^#]*)?(#.*)?$" +) +RAW_RE = re.compile( + rf"^https://raw\.githubusercontent\.com/{OWNER}/([\w.-]+)/(?:refs/(?:heads|tags)/)?([^/?#]+)(/[^?#]*)?" + r"(\?[^#]*)?(#.*)?$" +) +FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def parse_repo_link(url: str) -> RepoLink | None: + """The parts of an osprey-dcs blob/tree/raw URL, or None for any other URL (issues, PRs, compare, ...).""" + blob = BLOB_RE.match(url) + if blob: + repo, kind, ref, path, _query, anchor = blob.groups() + else: + raw = RAW_RE.match(url) + if not raw: + return None + repo, ref, path, _query, anchor = raw.groups() + kind = "raw" + return RepoLink( + repo=repo, + kind=kind, + ref=ref, + path=unquote((path or "").strip("/")), + anchor=unquote(anchor[1:]) if anchor and len(anchor) > 1 else None, + ) + + +def exists_exactly(root: Path, rel: str, kind: str) -> bool: + """Whether `rel` exists under `root` with exactly this spelling, as a directory for a tree link and a file + otherwise. Each component is matched against its directory listing rather than with Path.exists(), because + macOS file systems are case-insensitive and GitHub is not.""" + here = root + for part in [p for p in rel.split("/") if p]: + if part in (".", "..") or not here.is_dir() or part not in os.listdir(here): + return False + here = here / part + return here.is_dir() if kind == "tree" or not rel else here.is_file() + + +# ------------------------------------------------------------------------------------------------------------------ +# Link rules R1-R4 (rel-X.Y.Z.md) and N1-N3 (NEXT.md); placeholder rule R5 +# ------------------------------------------------------------------------------------------------------------------ + +PLACEHOLDER_RE = re.compile(r"rel-||") + + +def check_links(name: str, text: str, *, tag: str | None, full: bool, root: Path, cfg: Config) -> list[str]: + """The link rules for one file. `tag` is the file's tag, or None for NEXT.md (the N rules). `full` adds the + working-tree rules (R3/R4, N3); it is False for notes that are already released.""" + problems: list[str] = [] + stripped = strip_code(text) + this_repo = cfg.repository.split("/", 1)[1] + r1, r2, r3, r4 = ("R1", "R2", "R3", "R4") if tag else ("N1", "N2", "N3", "N3") + want_ref = tag or "main" + own_anchors: tuple[set[str], set[str]] | None = None + target_anchors: dict[str, tuple[set[str], set[str]]] = {} + + def where(pos: int) -> str: + return f"{name}:{line_of(stripped, pos)}" + + def check_anchor(pos: int, anchor: str, anchors: tuple[set[str], set[str]], target: str, rule: str) -> None: + if LINE_ANCHOR_RE.match(anchor): + return + known, duplicated = anchors + if anchor not in known: + problems.append(f"{where(pos)}: {rule}: no heading with anchor #{anchor} in {target}") + elif anchor in duplicated: + problems.append( + f"{where(pos)}: {rule}: #{anchor} points at a duplicated heading in {target}; rename one of them, " + "or the anchor moves when either changes" + ) + + for pos, target in link_targets(stripped): + if not ALLOWED_TARGET_RE.match(target): + problems.append( + f"{where(pos)}: {r1}: relative link '{target}' cannot resolve in a release body; use " + f"https://github.com/{cfg.repository}/blob/{want_ref}/" + ) + elif full and target.startswith("#") and len(target) > 1: + if own_anchors is None: + own_anchors = heading_anchors(text) + check_anchor(pos, unquote(target[1:]), own_anchors, "this file", r4) + + for pos, url in urls(stripped): + link = parse_repo_link(url) + if link is None: + continue + sha_pinned = tag is not None and FULL_SHA_RE.match(link.ref) is not None + if link.ref != want_ref and not sha_pinned: + expected = f"expected this release's tag {tag}" if tag else "NEXT.md links use main until the cut" + problems.append(f"{where(pos)}: {r2}: link pinned to '{link.ref}', {expected}: {url}") + continue + if not full or sha_pinned or link.repo != this_repo: + continue # released notes get the form rules only; a SHA's or another repo's tree is not checked out + if not exists_exactly(root, link.path, link.kind): + problems.append(f"{where(pos)}: {r3}: no {link.kind} '{link.path or '/'}' in this repository: {url}") + continue + if link.anchor and link.kind == "blob" and link.path.lower().endswith((".md", ".markdown")): + if link.path not in target_anchors: + target_anchors[link.path] = heading_anchors((root / link.path).read_text(encoding="utf-8")) + check_anchor(pos, link.anchor, target_anchors[link.path], link.path, r4) + + return problems + + +def check_placeholders(name: str, text: str) -> list[str]: + """R5, over the raw text: the placeholders live in code blocks, so nothing is stripped.""" + return [ + f"{name}:{line_of(text, m.start())}: R5: template placeholder '{m.group(0)}' left in a release note" + for m in PLACEHOLDER_RE.finditer(text) + ] + + +# ------------------------------------------------------------------------------------------------------------------ +# Identity rules: sigstore-python (dp-python-lib) +# ------------------------------------------------------------------------------------------------------------------ + +VERIFY_HEADING_RE = re.compile(r"^## Verifying these artifacts\s*$", re.MULTILINE) # The whole command, backslash continuations included, up to the first line that does not continue. -VERIFY_RE = re.compile(r"\bsigstore\s+verify\s+identity\b(?:[^\n]*\\\n)*[^\n]*") -IDENTITY_RE = re.compile(r"--cert-identity[ =]\"?([^\"\s]+)\"?") -ISSUER_RE = re.compile(r"--cert-oidc-issuer[ =]\"?([^\"\s]+)\"?") +SIGSTORE_VERIFY_RE = re.compile(r"\bsigstore\s+verify\s+identity\b(?:[^\n]*\\\n)*[^\n]*") +SIGSTORE_IDENTITY_RE = re.compile(r"--cert-identity[ =]\"?([^\"\s]+)\"?") +SIGSTORE_ISSUER_RE = re.compile(r"--cert-oidc-issuer[ =]\"?([^\"\s]+)\"?") # A command as written in a code block, rather than named in prose. -VERIFY_COMMAND_RE = re.compile(r"^[ \t]*sigstore\s+verify\s+identity\b", re.MULTILINE) +SIGSTORE_VERIFY_COMMAND_RE = re.compile(r"^[ \t]*sigstore\s+verify\s+identity\b", re.MULTILINE) CHANGELOG_RE = re.compile(r"^\*\*Full Changelog\*\*:\s*(\S+)\s*$", re.MULTILINE) -COMPARE_RE = re.compile( - rf"^https://github\.com/{re.escape(REPOSITORY)}/compare/(rel-\d+\.\d+\.\d+)\.\.\.(rel-\d+\.\d+\.\d+)$" -) # What each `sigstore verify identity` must name, as (description, predicate over its file operands). Every # release signs all three, and verifying only the wheel was exactly the shape rel-1.16.0's page first shipped with. @@ -72,14 +420,8 @@ ] -def expected_identity(tag: str) -> str: - return f"https://github.com/{REPOSITORY}/.github/workflows/release.yml@refs/tags/{tag}" - - -def version_of(tag: str) -> tuple[int, ...]: - match = STEM_RE.match(tag) - assert match is not None - return tuple(int(part) for part in match.groups()) +def expected_identity(tag: str, repository: str = REPOSITORY, workflow: str = "release.yml") -> str: + return f"https://github.com/{repository}/.github/workflows/{workflow}@refs/tags/{tag}" def verify_operands(command: str) -> list[str]: @@ -97,14 +439,14 @@ def verify_operands(command: str) -> list[str]: return operands -def check_text(name: str, tag: str, text: str) -> list[str]: - """Returns one message per problem found in the notes `text` for `tag`; empty when it passes.""" +def check_sigstore_python(name: str, tag: str, text: str, cfg: Config) -> list[str]: + """dp-python-lib's verification section and changelog link in a rel-X.Y.Z.md; empty when they pass.""" problems: list[str] = [] - if not HEADING_RE.search(text): + if not VERIFY_HEADING_RE.search(text): problems.append(f"{name}: missing a '## Verifying these artifacts' section") - commands = VERIFY_RE.findall(text) + commands = SIGSTORE_VERIFY_RE.findall(text) if not commands: problems.append(f"{name}: missing a 'sigstore verify identity' command") for command in commands: @@ -113,30 +455,33 @@ def check_text(name: str, tag: str, text: str) -> list[str]: if not any(matches(arg) for arg in operands): problems.append(f"{name}: 'sigstore verify identity' does not verify {description}") - identities = IDENTITY_RE.findall(text) + identities = SIGSTORE_IDENTITY_RE.findall(text) if not identities: problems.append(f"{name}: missing --cert-identity") - want = expected_identity(tag) + want = expected_identity(tag, cfg.repository) for identity in identities: if identity != want: problems.append(f"{name}: --cert-identity is\n {identity}\n expected\n {want}") - issuers = ISSUER_RE.findall(text) + issuers = SIGSTORE_ISSUER_RE.findall(text) if not issuers: problems.append(f"{name}: missing --cert-oidc-issuer") for issuer in issuers: if issuer != OIDC_ISSUER: problems.append(f"{name}: --cert-oidc-issuer is {issuer}, expected {OIDC_ISSUER}") + compare_re = re.compile( + rf"^https://github\.com/{re.escape(cfg.repository)}/compare/(rel-\d+\.\d+\.\d+)\.\.\.(rel-\d+\.\d+\.\d+)$" + ) changelogs = CHANGELOG_RE.findall(text) if not changelogs: problems.append(f"{name}: missing a '**Full Changelog**: .../compare/rel-...{tag}' line") for url in changelogs: - compare = COMPARE_RE.match(url) + compare = compare_re.match(url) if compare is None: problems.append( f"{name}: Full Changelog link is\n {url}\n expected" - f"\n https://github.com/{REPOSITORY}/compare/rel-...{tag}" + f"\n https://github.com/{cfg.repository}/compare/rel-...{tag}" ) continue prev, this = compare.groups() @@ -148,13 +493,13 @@ def check_text(name: str, tag: str, text: str) -> list[str]: return problems -def check_next_text(name: str, text: str) -> list[str]: - """Returns one message per tag-bearing part found in the NEXT.md draft `text`; empty when it has none.""" +def check_next_sigstore_python(name: str, text: str) -> list[str]: + """One message per tag-bearing verification part found in dp-python-lib's NEXT.md draft.""" problems: list[str] = [] forbidden = [ - (HEADING_RE, "a '## Verifying these artifacts' section"), - (VERIFY_COMMAND_RE, "a 'sigstore verify identity' command"), - (IDENTITY_RE, "a --cert-identity"), + (VERIFY_HEADING_RE, "a '## Verifying these artifacts' section"), + (SIGSTORE_VERIFY_COMMAND_RE, "a 'sigstore verify identity' command"), + (SIGSTORE_IDENTITY_RE, "a --cert-identity"), (CHANGELOG_RE, "a '**Full Changelog**' line"), ] for pattern, description in forbidden: @@ -166,18 +511,101 @@ def check_next_text(name: str, text: str) -> list[str]: return problems -def check_file(path: Path) -> list[str]: - """Returns one message per problem found in `path`; empty when it passes.""" +# ------------------------------------------------------------------------------------------------------------------ +# Identity rules: cosign (dp-service, dp-desktop-app, dp-grpc) +# ------------------------------------------------------------------------------------------------------------------ + + +def option_values(option: str, text: str) -> list[tuple[int, str]]: + """(offset, value) of every `option VALUE` / `option=VALUE`, the value optionally single- or double-quoted. + `--certificate-identity` does not match `--certificate-identity-regexp`, nor prose naming the flag in + backticks.""" + pattern = re.compile(rf"{re.escape(option)}[ =](?:'([^'\n]*)'|\"([^\"\n]*)\"|([^\s'\"]+))") + return [(m.start(), next(g for g in m.groups() if g is not None)) for m in pattern.finditer(text)] + + +def check_cosign(name: str, tag: str, text: str, cfg: Config) -> list[str]: + """The cosign identity rules switched on in `cfg`, over a rel-X.Y.Z.md's raw text; empty when they pass.""" + problems: list[str] = [] + if cfg.cosign_workflows: + allowed = [expected_identity(tag, cfg.repository, workflow) for workflow in cfg.cosign_workflows] + for pos, identity in option_values("--certificate-identity", text): + if identity not in allowed: + problems.append( + f"{name}:{line_of(text, pos)}: --certificate-identity is\n {identity}\n expected one of" + + "".join(f"\n {a}" for a in allowed) + ) + if cfg.cosign_image: + for m in re.finditer(rf"{re.escape(cfg.cosign_image)}:(rel-[^\s'\"`)]*)", text): + if m.group(1) != tag: + problems.append( + f"{name}:{line_of(text, m.start())}: image {cfg.cosign_image}:{m.group(1)}, expected :{tag}" + ) + if cfg.cosign_identity_regexp: + for pos, regexp in option_values("--certificate-identity-regexp", text): + if regexp != cfg.cosign_identity_regexp: + problems.append( + f"{name}:{line_of(text, pos)}: --certificate-identity-regexp is\n {regexp}\n" + f" expected exactly\n {cfg.cosign_identity_regexp}" + ) + if cfg.cosign_workflows or cfg.cosign_image or cfg.cosign_identity_regexp: + for pos, issuer in option_values("--certificate-oidc-issuer", text): + if issuer != OIDC_ISSUER: + problems.append( + f"{name}:{line_of(text, pos)}: --certificate-oidc-issuer is {issuer}, expected {OIDC_ISSUER}" + ) + return problems + + +# ------------------------------------------------------------------------------------------------------------------ +# Per-file entry points +# ------------------------------------------------------------------------------------------------------------------ + + +def check_release_text( + name: str, tag: str, text: str, *, released: bool, root: Path = REPO_ROOT, cfg: Config = CONFIG +) -> list[str]: + """Every rule for a rel-X.Y.Z.md; `released` drops the working-tree rules (R3, R4).""" + problems: list[str] = [] + if cfg.sigstore_python: + problems += check_sigstore_python(name, tag, text, cfg) + problems += check_cosign(name, tag, text, cfg) + problems += check_links(name, text, tag=tag, full=not released, root=root, cfg=cfg) + problems += check_placeholders(name, text) + return problems + + +def check_next_text(name: str, text: str, *, root: Path = REPO_ROOT, cfg: Config = CONFIG) -> list[str]: + """Every rule for the NEXT.md draft.""" + problems: list[str] = [] + if cfg.sigstore_python: + problems += check_next_sigstore_python(name, text) + problems += check_links(name, text, tag=None, full=True, root=root, cfg=cfg) + return problems + + +def check_file(path: Path) -> tuple[list[str], str]: + """(one message per problem found in `path`, which rules it was checked under).""" if path.name == NEXT_NAME: - return check_next_text(str(path), path.read_text(encoding="utf-8")) + return check_next_text(str(path), path.read_text(encoding="utf-8")), "draft rules (N1-N3)" tag = path.stem if path.suffix != ".md" or not STEM_RE.match(tag): - return [f"{path}: name must be rel-X.Y.Z.md or {NEXT_NAME} (release.yml looks the notes up by tag)"] - return check_text(str(path), tag, path.read_text(encoding="utf-8")) + return [f"{path}: name must be rel-X.Y.Z.md or {NEXT_NAME} (release.yml looks the notes up by tag)"], "-" + released = is_released(path) + problems = check_release_text(str(path), tag, path.read_text(encoding="utf-8"), released=released) + return problems, "already released: form rules (R1, R2, R5)" if released else "newest: all rules (R1-R5)" + + +# ------------------------------------------------------------------------------------------------------------------ +# Self-test +# ------------------------------------------------------------------------------------------------------------------ +_TAG = "rel-2.1.0" +_PY = "https://github.com/osprey-dcs/dp-python-lib" -def _sample( - identity_tag: str = "rel-2.1.0", + +def _sigstore_sample( + identity_tag: str = _TAG, files: str = "dp_python_lib-*.whl dp_python_lib-*.tar.gz SHA256SUMS", changelog: str | None = "rel-2.0.0...rel-2.1.0", ) -> str: @@ -187,63 +615,296 @@ def _sample( ```bash sigstore verify identity \\ - --cert-identity "{expected_identity(identity_tag)}" \\ + --cert-identity "{expected_identity(identity_tag, "osprey-dcs/dp-python-lib")}" \\ --cert-oidc-issuer "{OIDC_ISSUER}" \\ {files} ``` """ if changelog is not None: - notes += f"\n**Full Changelog**: https://github.com/{REPOSITORY}/compare/{changelog}\n" + notes += f"\n**Full Changelog**: {_PY}/compare/{changelog}\n" return notes -def self_test() -> list[str]: - """Confirms a correct sample passes and each known-bad variant is rejected; returns a message per failure.""" - failures: list[str] = [] - good = check_text("", "rel-2.1.0", _sample()) - if good: - failures.append("a correct sample was rejected:\n " + "\n ".join(good)) - - bad_cases = { - "a stale --cert-identity tag": _sample(identity_tag="rel-2.0.0"), - "a verify of the wheel only": _sample(files="dp_python_lib-*.whl"), - "a verify missing SHA256SUMS": _sample(files="dp_python_lib-*.whl dp_python_lib-*.tar.gz"), - "a verify missing the sdist": _sample(files="dp_python_lib-*.whl SHA256SUMS"), - "no Full Changelog line": _sample(changelog=None), - "a Full Changelog link ending at a stale tag": _sample(changelog="rel-1.9.0...rel-2.0.0"), - "a Full Changelog link starting at a later tag": _sample(changelog="rel-2.2.0...rel-2.1.0"), - "a Full Changelog link to another repository": _sample().replace( - f"{REPOSITORY}/compare", "someone-else/dp-python-lib/compare" - ), - "no verification heading": _sample().replace("## Verifying these artifacts", "## Verification"), - } - for description, notes in bad_cases.items(): - if not check_text(f"<{description}>", "rel-2.1.0", notes): - failures.append(f"{description} was accepted") +def _make_tree(root: Path) -> None: + """A minimal working tree for R3/R4/N3: a README with a duplicated heading and an HTML anchor, and a doc dir.""" + (root / "doc").mkdir() + (root / "README.md").write_text( + "# Project\n\n## Configuration priority\n\n### Methods\n\n### Methods\n\n" + '\n\n```\n## Not a heading\n```\n', + encoding="utf-8", + ) + (root / "doc" / "guide.md").write_text("# Guide\n\n## Internal: the `_dispatch` refactor (Issue #14)\n") + (root / "README.env").write_text("verification reference\n") - # The draft's own checklist names each forbidden part in prose; that must not trip the rule. - next_draft = """# Release Notes — next release (unreleased) + +# Links that must pass in the newest notes (rel-2.1.0), against the tree from _make_tree. +_GOOD_LINKS = f"""# Notes + +## Installing + +- [config]({_PY}/blob/{_TAG}/README.md#configuration-priority) and [guide][guide] +- [dispatch]({_PY}/blob/{_TAG}/doc/guide.md#internal-the-_dispatch-refactor-issue-14) +- [html anchor]({_PY}/blob/{_TAG}/README.md#pinned), [env]({_PY}/blob/{_TAG}/README.env) +- [doc dir]({_PY}/tree/{_TAG}/doc), raw: https://raw.githubusercontent.com/osprey-dcs/dp-python-lib/{_TAG}/README.env +- [dp-grpc notes](https://github.com/osprey-dcs/dp-grpc/blob/{_TAG}/doc/release-notes/{_TAG}.md#anything) +- [added later]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/not/in/this/tree.md) +- [issue]({_PY}/issues/7), [mail](mailto:x@example.org), [up](#installing), <{_PY}/pull/9> +- quoted, not linked: `[x](../../README.md)` and `{_PY}/blob/main/README.md` + +```markdown +See [the README](../../README.md#x) or [on main]({_PY}/blob/main/README.md). +[stale]: {_PY}/blob/rel-1.0.0/README.md +``` + +[guide]: {_PY}/blob/{_TAG}/doc/guide.md +""" + +# A NEXT.md that must pass: links on main, and a checklist quoting everything the release rules forbid. +_GOOD_NEXT = f"""# Release Notes -- next release (unreleased) + +- [config]({_PY}/blob/main/README.md#configuration-priority), [guide][guide], [down](#cutting-the-release) +- [dp-grpc](https://github.com/osprey-dcs/dp-grpc/blob/main/README.md) + +## Cutting the release 4. **Add the `## Verifying these artifacts` section**: `sigstore verify identity` over all three files, with `--cert-identity` ending `release.yml@refs/tags/rel-`. 5. **End with the Full Changelog line**: - `**Full Changelog**: https://github.com/osprey-dcs/dp-python-lib/compare/rel-...rel-`. + `**Full Changelog**: {_PY}/compare/rel-...rel-`. +6. Repoint `{_PY}/blob/main/...` links to `blob/rel-/...`; `](../x.md)` is quoted here. + +```bash +cosign verify-blob --certificate-identity 'x@refs/tags/rel-' # [a](../a.md) +``` + +[guide]: {_PY}/blob/main/doc/guide.md """ - good_next = check_next_text("", next_draft) + + +def _self_test_links(root: Path) -> list[str]: + failures: list[str] = [] + cfg = replace(CONFIG, repository="osprey-dcs/dp-python-lib") + + def release(text: str, released: bool = False) -> list[str]: + return check_links("", text, tag=_TAG, full=not released, root=root, cfg=cfg) + check_placeholders( + "", text + ) + + def draft(text: str) -> list[str]: + return check_links("", text, tag=None, full=True, root=root, cfg=cfg) + + for description, problems in { + "good links in the newest notes": release(_GOOD_LINKS), + "good links in released notes": release(_GOOD_LINKS, released=True), + "a good NEXT.md's links": draft(_GOOD_NEXT), + }.items(): + if problems: + failures.append(f"{description} were rejected:\n " + "\n ".join(problems)) + + form_rules = { + "R1 a ../ relative link": "[r](../../README.md#x)", + "R1 a bare relative path": "[r](doc/guide.md)", + "R1 a relative reference definition": "[r]: doc/guide.md", + "R1 a relative image": "![i](img/x.png)", + "R2 a link left on main": f"[m]({_PY}/blob/main/README.md)", + "R2 a main-pinned reference definition": f"[readme-env]: {_PY}/blob/main/README.env", + "R2 a stale tag": f"[s]({_PY}/blob/rel-2.0.0/README.md)", + "R2 a stale tag into another repo": "[g](https://github.com/osprey-dcs/dp-grpc/blob/rel-2.0.0/README.md)", + "R2 a tree link on main": f"[t]({_PY}/tree/main/doc)", + "R2 a raw link on main": "https://raw.githubusercontent.com/osprey-dcs/dp-service/main/README.md", + "R2 a bare URL on main": f"See {_PY}/blob/main/README.md.", + "R2 a short SHA": f"[s]({_PY}/blob/0123456/README.md)", + "R5 a leftover rel-": "```\n--certificate-identity '...@refs/tags/rel-'\n```", + "R5 a leftover ": f"`{_PY}/compare/...rel-2.1.0`", + } + tree_rules = { + "R3 a missing path": f"[p]({_PY}/blob/{_TAG}/doc/missing.md)", + "R3 a path in the wrong case": f"[p]({_PY}/blob/{_TAG}/readme.md)", + "R3 a blob link to a directory": f"[p]({_PY}/blob/{_TAG}/doc)", + "R4 a missing anchor": f"[a]({_PY}/blob/{_TAG}/README.md#configuration)", + "R4 an anchor only inside a code block": f"[a]({_PY}/blob/{_TAG}/README.md#not-a-heading)", + "R4 an anchor to a duplicated heading": f"[d]({_PY}/blob/{_TAG}/README.md#methods)", + "R4 an anchor to a duplicate's -1": f"[d]({_PY}/blob/{_TAG}/README.md#methods-1)", + "R4 a missing same-document anchor": "[c](#contents)", + } + for description, extra in {**form_rules, **tree_rules}.items(): + if not release(f"{_GOOD_LINKS}\n{extra}\n"): + failures.append(f"{description} was accepted") + for description, extra in form_rules.items(): + if not release(f"{_GOOD_LINKS}\n{extra}\n", released=True): + failures.append(f"{description} was accepted in already released notes") + for description, extra in tree_rules.items(): + if release(f"{_GOOD_LINKS}\n{extra}\n", released=True): + failures.append(f"{description} was rejected in already released notes, which get the form rules only") + + for description, extra in { + "N1 a relative link": "[r](../README.md)", + "N1 a relative reference definition": "[r]: ./doc/guide.md", + "N2 a rel- tag": f"[t]({_PY}/blob/rel-2.1.0/README.md)", + "N2 a rel- reference definition into another repo": "[g]: https://github.com/osprey-dcs/dp-grpc/blob/rel-2.1.0/x", + "N2 a commit SHA": f"[s]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/README.md)", + "N3 a missing path": f"[p]({_PY}/blob/main/doc/gone.md)", + "N3 a missing anchor": f"[a]({_PY}/blob/main/README.md#configuration-priorities)", + "N3 an anchor to a duplicated heading": f"[d]({_PY}/blob/main/README.md#methods)", + "N3 a missing same-document anchor": "[c](#contents)", + }.items(): + if not draft(f"{_GOOD_NEXT}\n{extra}\n"): + failures.append(f"NEXT.md with {description} was accepted") + + # A duplicate heading in the notes themselves moves a same-document anchor just the same. + if not release("# N\n\n## Methods\n\n## Methods\n\n[m](#methods)\n"): + failures.append("R4 a same-document anchor to a duplicated heading was accepted") + if release("# N\n\n## Methods\n\n### Methods\n\n[n](#n)\n"): + failures.append("a duplicate heading that no link points at was rejected") + return failures + + +def _self_test_released_rule() -> list[str]: + names = ["rel-1.9.0.md", "rel-1.16.0.md", "rel-1.10.2.md", "NEXT.md", "README.md"] + newest = newest_release(names) + if newest != "rel-1.16.0": + return [f"the newest of {names} was {newest}, expected rel-1.16.0 (versions compare numerically)"] + return [] + + +def _self_test_sigstore_python() -> list[str]: + failures: list[str] = [] + cfg = replace(CONFIG, repository="osprey-dcs/dp-python-lib") + good = check_sigstore_python("", _TAG, _sigstore_sample(), cfg) + if good: + failures.append("a correct sigstore sample was rejected:\n " + "\n ".join(good)) + for description, notes in { + "a stale --cert-identity tag": _sigstore_sample(identity_tag="rel-2.0.0"), + "a verify of the wheel only": _sigstore_sample(files="dp_python_lib-*.whl"), + "a verify missing SHA256SUMS": _sigstore_sample(files="dp_python_lib-*.whl dp_python_lib-*.tar.gz"), + "a verify missing the sdist": _sigstore_sample(files="dp_python_lib-*.whl SHA256SUMS"), + "no Full Changelog line": _sigstore_sample(changelog=None), + "a Full Changelog link ending at a stale tag": _sigstore_sample(changelog="rel-1.9.0...rel-2.0.0"), + "a Full Changelog link starting at a later tag": _sigstore_sample(changelog="rel-2.2.0...rel-2.1.0"), + "a Full Changelog link to another repository": _sigstore_sample().replace( + "dp-python-lib/compare", "dp-grpc/compare" + ), + "no verification heading": _sigstore_sample().replace("## Verifying these artifacts", "## Verification"), + }.items(): + if not check_sigstore_python(f"<{description}>", _TAG, notes, cfg): + failures.append(f"{description} was accepted") + + # The draft's own checklist names each forbidden part in prose; that must not trip the rule. + good_next = check_next_sigstore_python("", _GOOD_NEXT) if good_next: failures.append("a correct NEXT.md was rejected:\n " + "\n ".join(good_next)) for description, notes in { - "a NEXT.md with a verification section": next_draft + "\n## Verifying these artifacts\n", - "a NEXT.md with a verify command": next_draft + "\n```bash\nsigstore verify identity \\\n```\n", - "a NEXT.md with a signing identity": next_draft + f'\n --cert-identity "{expected_identity("rel-2.1.0")}"\n', - "a NEXT.md with a Full Changelog line": next_draft - + f"\n**Full Changelog**: https://github.com/{REPOSITORY}/compare/rel-2.0.0...rel-2.1.0\n", + "a NEXT.md with a verification section": _GOOD_NEXT + "\n## Verifying these artifacts\n", + "a NEXT.md with a verify command": _GOOD_NEXT + "\n```bash\nsigstore verify identity \\\n```\n", + "a NEXT.md with a signing identity": _GOOD_NEXT + f'\n --cert-identity "{expected_identity(_TAG)}"\n', + "a NEXT.md with a Full Changelog line": _GOOD_NEXT + + f"\n**Full Changelog**: {_PY}/compare/rel-2.0.0...{_TAG}\n", }.items(): - if not check_next_text(f"<{description}>", notes): + if not check_next_sigstore_python(f"<{description}>", notes): + failures.append(f"{description} was accepted") + return failures + + +def _self_test_cosign() -> list[str]: + """Every cosign rule under the configuration of the repo it is written for, whichever repo this copy is in.""" + failures: list[str] = [] + issuer = f"--certificate-oidc-issuer {OIDC_ISSUER}" + service = Config( + "osprey-dcs/dp-service", False, ("release.yml", "release-image.yml"), "ghcr.io/osprey-dcs/dp-service", None + ) + desktop = Config("osprey-dcs/dp-desktop-app", False, ("release.yml",), None, None) + grpc = Config("osprey-dcs/dp-grpc", False, (), None, DP_GRPC_IDENTITY_REGEXP) + no_cosign = Config("osprey-dcs/data-platform", False, (), None, None) + + def ident(repo: str, workflow: str = "release.yml", tag: str = _TAG) -> str: + return expected_identity(tag, f"osprey-dcs/{repo}", workflow) + + service_blob, service_image = ident("dp-service"), ident("dp-service", "release-image.yml") + service_good = ( + f"cosign verify-blob --certificate-identity '{service_blob}' {issuer} SHA256SUMS\n" + f"cosign verify --certificate-identity '{service_image}' \\\n {issuer} \\\n" + f" ghcr.io/osprey-dcs/dp-service:{_TAG}\n" + "Prose naming `--certificate-identity` is not an identity.\n" + ) + desktop_id = ident("dp-desktop-app") + desktop_good = ( + f"cosign verify-blob --certificate-identity '{desktop_id}' {issuer} SHA256SUMS\n" + f'cosign verify-blob --certificate-identity "{desktop_id}" {issuer} ' + "--certificate-github-workflow-trigger push SHA256SUMS\n" + ) + grpc_good = ( + f"cosign verify-blob \\\n --certificate-identity-regexp '{DP_GRPC_IDENTITY_REGEXP}' \\\n" + f" {issuer} \\\n SHA256SUMS\n" + ) + for description, cfg, text in [ + ("dp-service", service, service_good), + ("dp-desktop-app", desktop, desktop_good), + ("dp-grpc", grpc, grpc_good), + ("a repo with no cosign rules, over stale identities,", no_cosign, service_good.replace(_TAG, "rel-2.0.0")), + ]: + problems = check_cosign("", _TAG, text, cfg) + if problems: + failures.append(f"correct {description} cosign notes were rejected:\n " + "\n ".join(problems)) + + stale = "rel-2.0.0" + for description, cfg, text in [ + ( + "dp-service: a stale blob identity", + service, + service_good.replace(service_blob, ident("dp-service", tag=stale)), + ), + ( + "dp-service: a stale image identity", + service, + service_good.replace(service_image, ident("dp-service", "release-image.yml", stale)), + ), + ("dp-service: a stale image tag", service, service_good.replace(f"dp-service:{_TAG}", f"dp-service:{stale}")), + ("dp-service: an identity for another workflow", service, service_good.replace("release-image.yml", "ci.yml")), + ( + "dp-service: an identity for another repo", + service, + service_good.replace("dp-service/.github", "dp-grpc/.github"), + ), + ("dp-service: a wrong issuer", service, service_good.replace(OIDC_ISSUER, "https://accounts.google.com")), + ( + "dp-desktop-app: a stale single-quoted identity", + desktop, + desktop_good.replace(f"'{desktop_id}'", f"'{ident('dp-desktop-app', tag=stale)}'"), + ), + ( + "dp-desktop-app: a stale double-quoted identity", + desktop, + desktop_good.replace(f'"{desktop_id}"', f'"{ident("dp-desktop-app", tag=stale)}"'), + ), + ( + "dp-desktop-app: an identity placeholder", + desktop, + desktop_good.replace(f"'{desktop_id}'", f"'{ident('dp-desktop-app', tag='rel-')}'"), + ), + ( + "dp-desktop-app: release-image.yml, which it does not publish", + desktop, + desktop_good.replace("release.yml", "release-image.yml", 1), + ), + ("dp-grpc: an unanchored regexp", grpc, grpc_good.replace("'^https", "'https")), + ("dp-grpc: a regexp for any workflow", grpc, grpc_good.replace(r"release\.yml", ".*")), + ("dp-grpc: a regexp without the rel- prefix", grpc, grpc_good.replace("refs/tags/rel-'", "refs/tags/'")), + ]: + if not check_cosign(f"<{description}>", _TAG, text, cfg): failures.append(f"{description} was accepted") return failures +def self_test() -> list[str]: + """Confirms the good samples pass and each known-bad variant is rejected; returns a message per failure.""" + failures = _self_test_released_rule() + _self_test_sigstore_python() + _self_test_cosign() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _make_tree(root) + failures += _self_test_links(root) + return failures + + def main(argv: list[str]) -> int: canary_failures = self_test() if canary_failures: @@ -267,10 +928,12 @@ def main(argv: list[str]) -> int: if not path.is_file(): problems.append(f"{path}: not found") continue - problems.extend(check_file(path)) + file_problems, rules = check_file(path) + print(f" {path}: {rules}{f' -- {len(file_problems)} problem(s)' if file_problems else ''}") + problems.extend(file_problems) if problems: - print(f"FAIL: {len(problems)} problem(s) in {len(paths)} release notes file(s)\n") + print(f"\nFAIL: {len(problems)} problem(s) in {len(paths)} release notes file(s)\n") for problem in problems: print(f" {problem}") return 1 From 0e9050c7f6442a2f0016c3e1148769842777ad17 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 10:59:00 -0600 Subject: [PATCH 3/7] Document the release-notes link rules; point the cut checklist at the checker (#70) NEXT.md's "Cutting the release" steps 6 and 8 now rely on the checker to list missed links and leftover placeholders rather than on the reader, and its preamble states the link convention. CLAUDE.md records the rules and which notes count as already released. plan/tickets/70/plan.md records the triage findings and design decisions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- CLAUDE.md | 17 ++++++++++ doc/release-notes/NEXT.md | 26 +++++++++----- plan/tickets/70/plan.md | 71 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 plan/tickets/70/plan.md diff --git a/CLAUDE.md b/CLAUDE.md index e20cd87..6b86e96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,23 @@ so, in the draft, guesses it. Only real ones count -- a heading or command at t identity with a value, a changelog line at the start of a line -- because the draft's own checklist names all four in prose, and the self-test holds that prose to passing. +**Links in the notes are checked too** (#70; the rules, R1–R5 and N1–N3, are osprey-dcs/data-platform#98 +and the script's docstring). A `rel-*.md` may have no relative links (they 404 in a release body), +and every `github.com/osprey-dcs//blob|tree/` or `raw.githubusercontent.com` link, into +any of the five repos, must be pinned to the file's own tag: `main` drifts, and a tag copied from the +previous notes is stale. The one exception is a full 40-character commit SHA, for a target that did +not exist at the tag; rel-1.16.0's `README.env` link is pinned that way, because the verification +section was added after the release and `blob/rel-1.16.0/README.env` is a 404. Paths and `#anchors` +into this repo must exist in the working tree, and an anchor may not point at a duplicated heading. +No `rel-`, ``, or `` placeholder may be left. `NEXT.md` is the other way +round: links stay on `main`, and their paths and anchors are checked, so renaming a heading it links +to fails the renaming PR. **Which notes count as already released:** every `rel-*.md` except the +highest version. Those get only the form rules (relative links, pinning, placeholders), since +checking an immutable file against today's tree would fail it the first time a heading is renamed. +The script is copied verbatim into dp-grpc, dp-service, dp-desktop-app, and data-platform, which +differ only in the configuration block at its top (repository name and which signing-identity rules +apply); it already carries their cosign rules, self-tested, so a fix here is ported by copying the file. + **Never edit a release page by hand.** Fix the notes file by PR, then republish with `gh release edit rel-X.Y.Z --notes-file doc/release-notes/rel-X.Y.Z.md`. With the file as the whole body that is lossless; when the workflow appended a verification section, exactly this diff --git a/doc/release-notes/NEXT.md b/doc/release-notes/NEXT.md index df34872..b6eb73a 100644 --- a/doc/release-notes/NEXT.md +++ b/doc/release-notes/NEXT.md @@ -19,6 +19,11 @@ signing identity, or a Full Changelog line. Those are written at the cut. Nami is fine, as the checklist below does, but put the name in backticks: the checker treats an unquoted `--cert-identity` followed by a word, or a line beginning with the verify command, as the real thing. +Links here are absolute `https://github.com/osprey-dcs//blob/main/...` URLs: never relative, +and never a `rel-*` tag, which would guess the version. The same checker confirms that each link +into this repo names a file and heading that exist, so a PR that renames a heading linked from here +fails CI until the link is fixed. The links move to the release tag at the cut (step 6 below). + Nothing here should assert what *else* the release contains, either: that is knowable only once the release is cut, and a stale claim in a file that already looks finished is not something the person cutting the release has any reason to re-read. @@ -291,16 +296,19 @@ When the version is known and the release is being cut: the pointer to `README.env`. 5. **End with the Full Changelog line**, after `## Installing`: `**Full Changelog**: https://github.com/osprey-dcs/dp-python-lib/compare/rel-...rel-`. -6. **Repoint `blob/main/...` links to `blob/rel-/...`.** This file is published as the - release body via `body_path`, and relative links do not survive that lift — they resolve against - the repo root, not `doc/release-notes/`, and 404. Links here are already absolute for that - reason, but one pinned to `main` drifts as the repo moves on; pinned to the tag it keeps - describing the content this release actually shipped. (`README.env` did not exist at 1.16.0, - so a link to it from older notes stays on `main`.) +6. **Repoint every `blob/main/...` link to `blob/rel-/...`**, including links into the + other osprey-dcs repos, which release in lockstep. This file is published as the release body + via `body_path`, where a relative link 404s and a `main` link drifts as the repo moves on; pinned + to the tag it keeps describing the content this release actually shipped. Don't hunt for them + by eye: step 8 lists every one you missed, and any stale `rel-*` tag copied from older notes. 7. **Delete this "Cutting the release" section** and update Contents. -8. **Run `python .dev/tools/check-release-notes.py`**, which CI also runs on the PR. It fails on a - missing verification section, a stale tag in the identity or the changelog link, or a verify - command that skips a file. +8. **Run `python .dev/tools/check-release-notes.py`** and fix everything it lists; CI runs it on the + PR too. For the new file it fails on a relative link; a link into any osprey-dcs repo not + pinned to `rel-`; a path or `#anchor` into this repo that is missing from the tree being + tagged, or that points at a duplicated heading; a leftover `rel-`, ``, or + ``; a missing verification section; a stale tag in the identity or the changelog + link; or a verify command that skips a file. The rules are in the script's docstring + (osprey-dcs/data-platform#98). 9. **Start a fresh `NEXT.md`** for the following cycle. Steps 1, 2, and 7 have moved, rewritten, and deleted the text it needs, so recover it from `main`: `git show main:doc/release-notes/NEXT.md > doc/release-notes/NEXT.md`, then delete every ticket diff --git a/plan/tickets/70/plan.md b/plan/tickets/70/plan.md new file mode 100644 index 0000000..b200fbf --- /dev/null +++ b/plan/tickets/70/plan.md @@ -0,0 +1,71 @@ +# Issue #70 — Release notes checker: link rules, and the main-pinned link in rel-1.16.0 + +**Status:** implemented 2026-10-01 in the PR that closes #70. Part of osprey-dcs/data-platform#98, +which holds the rules (R1–R5, N1–N3) and their rationale; they are not repeated here. + +## Overview + +`.dev/tools/check-release-notes.py` gains #98's link and placeholder rules, and becomes the copy the +other four repos take verbatim, changing only a configuration block. rel-1.16.0's one `main`-pinned +link is fixed in the file and, after merge, on the published release page. For whoever cuts +releases and reviews PRs that touch release notes or headings they link to. + +## Background / triage findings + +- **`blob/rel-1.16.0/README.env` is a 404.** README.env was added in 700d2ed (#56), after the tag, + together with the backported verification section that links it. Repointing line 410 to the tag, + as #70 and #98 say, would replace a drifting link with a broken one. `NEXT.md`'s checklist already + noted this ("`README.env` did not exist at 1.16.0"). See D3. +- **The release body is no longer generated.** #98 (and its second comment) describe a body built + by `cat`ing the notes and appending a verification section. Since #56 the notes file is the body, + verbatim (`release.yml` copies it to `dist/RELEASE_NOTES.md`), and the published rel-1.16.0 body is + byte-identical to the file. Regenerating it the way `release.yml` does is therefore the file + itself: `gh release edit rel-1.16.0 --notes-file doc/release-notes/rel-1.16.0.md`. +- rel-1.16.0.md has one other duplicated heading (`### Methods`, twice), which no link points at. + +## Design decisions + +**D1 — Released notes are every `rel-*.md` except the highest version** (#98's suggestion). Offline, +needs only the directory listing, and behaves the same in CI and in `release.yml`, which passes one +file; the comparison is still against that file's directory. *Rejected:* "has a local git tag", +since CI checkouts and release checkouts differ in which tags they hold, and the release being cut +would count as released at tag time. Consequence accepted: between cuts the latest release is +checked against the moving tree, so a rename breaking one of its links fails that PR. + +**D2 — R4's duplicate-heading rule applies to the heading a link points at**, not to every heading +in the target. A duplicate is only harmful when a link's anchor is one of the `x`/`x-1` pair; +flagging unlinked duplicates would fail rel-1.16.0 on its two `### Methods` for no benefit. + +**D3 — R2 accepts a full 40-character commit SHA**, and rel-1.16.0's `[readme-env]` is pinned to +`blob/700d2edf44776ad86ee220836db1facf340d4629/README.env` (identical to `main`'s README.env today). +A SHA is immutable, so it has neither defect R2 exists for; short SHAs, `main`, and other tags still +fail, and NEXT.md (N2) still requires `main`. SHA-pinned links get R2 only, like cross-repo links. +*Rejected:* the tag (404); leaving `main` behind a per-link allow marker (still drifts, and #70's +done-when is "no `main`-pinned links"). **Needs confirmation before merge** — it departs from #98's +wording of R2. + +**D4 — One file, a marked configuration block.** `REPOSITORY`, `NOTES_DIR_IN_REPO`, and four +identity switches (`SIGSTORE_PYTHON_RULES`, `COSIGN_IDENTITY_WORKFLOWS`, `COSIGN_IMAGE`, +`COSIGN_IDENTITY_REGEXP`); the block's comment gives each repo's values. The cosign rules for +dp-service, dp-desktop-app, and dp-grpc are implemented and self-tested under their own +configurations in every copy, so a port is configuration only. + +**D5 — Bare URLs and autolinks count for R2–R4**; GitHub links them. R1 looks only at inline and +reference-definition targets. Paths are matched case-sensitively per component, since macOS is not. + +## Implementation tasks + +- `.dev/tools/check-release-notes.py`: the rules, the configuration block, and self-test cases for + each rule (good and bad, newest vs released, draft), all three cosign profiles, and the released + rule's numeric ordering. Verified by mutation: disabling any rule fails the self-test. +- `doc/release-notes/rel-1.16.0.md:410`: SHA pin (D3). +- `doc/release-notes/NEXT.md`: preamble paragraph on links; checklist steps 6 and 8 point at the + checker. +- `CLAUDE.md`: the link rules and the released-notes rule, under "Release notes". +- No workflow change: CI and `release.yml` already run the script; the `push` guard stays. + +## Out of scope + +- Republishing the rel-1.16.0 page: done after merge, by hand, with the command above (#70's task). +- Porting to dp-grpc, dp-service, dp-desktop-app, data-platform: #98 steps 2–3. +- Recording the rule in data-platform's `CLAUDE.md`: #98. From 873b62230f485fa5dbd1b1505c1928f184207568 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 11:29:52 -0600 Subject: [PATCH 4/7] Move the release-notes checker to .github/scripts; narrow R5; neutral docstring (#70) Decisions made after the four ports reported back (data-platform#98): - The script lives at .github/scripts/check-release-notes.py in all five repos. CI, release.yml, CLAUDE.md, NEXT.md and the #70 plan point there; release.yml keeps its push-only guard, and no `uses:` pin changed. Plans for earlier tickets keep the old path, as a record of where it was. The new path is not gitignored, so ruff now lints and formats it by default. - The repository root is the first directory at or above the script containing .git (a directory, or a file in a worktree), not a fixed parents[2]; with none, it exits with a clear message. Self-tested for both .git forms and for its absence. - R5 catches only rel- and . A bare is used deliberately in prose that survives the cut (dp-service-.jar.sha256), and now passes. - Nothing outside the configuration block is specific to dp-python-lib: no fixed usage path, no plan/tickets/56 citation, no body_path wording, no rel-1.16.0 history. - The docstring says releases/tag/... and compare/... links are deliberately left to R2's silence, since linking an earlier release is legitimate; a self-test case holds that. - The self-test's fixture files are written with encoding="utf-8". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- .../scripts}/check-release-notes.py | 101 ++++++++++++++---- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CLAUDE.md | 14 ++- doc/release-notes/NEXT.md | 16 +-- plan/tickets/70/plan.md | 18 +++- 6 files changed, 117 insertions(+), 36 deletions(-) rename {.dev/tools => .github/scripts}/check-release-notes.py (91%) diff --git a/.dev/tools/check-release-notes.py b/.github/scripts/check-release-notes.py similarity index 91% rename from .dev/tools/check-release-notes.py rename to .github/scripts/check-release-notes.py index 68450e4..b59fe51 100755 --- a/.dev/tools/check-release-notes.py +++ b/.github/scripts/check-release-notes.py @@ -7,11 +7,10 @@ are osprey-dcs/data-platform#98. The self-test exercises every rule, including identity rules a given copy does not enable, so a copy whose rules have stopped matching fails on its first run. -A notes file is published verbatim as its GitHub release body (here via release.yml's `body_path`; -plan/tickets/56/plan.md), so whatever the file says is what readers of the release page get. GitHub does not -resolve relative links in a release body, a link pinned to `main` drifts as the repo moves on, and a link copied -from the previous release's notes resolves to real but stale content. None of that looks wrong in a diff or a -local preview, which is why it is checked rather than left to review. +A notes file is published verbatim as its GitHub release body, so whatever the file says is what readers of the +release page get. GitHub does not resolve relative links in a release body, a link pinned to `main` drifts as the +repo moves on, and a link copied from the previous release's notes resolves to real but stale content. None of +that looks wrong in a diff or a local preview, which is why it is checked rather than left to review. LINK RULES. Code spans and fenced code blocks are blanked out first, so text that *quotes* a bad link does not fail; R5 runs on the raw text, because the placeholders it catches live in code blocks. A link is an inline @@ -23,7 +22,7 @@ R2 Every github.com/osprey-dcs//(blob|tree)//... and raw.githubusercontent.com/osprey-dcs// /... link, into any of the five repos, has equal to this file's tag (they release in lockstep). Exception: a full 40-character commit SHA, for a target that did not exist at the tag -- a section added - to the notes after the release, such as rel-1.16.0's README.env link. A SHA cannot drift; `main`, a short + to the notes after the release, linking a file added after the tag. A SHA cannot drift; `main`, a short SHA, or any other tag still fails. R3 For tag-pinned links into this repo, the path exists in the working tree, spelled exactly (GitHub is case-sensitive; macOS is not), as a file for blob/raw and a directory for tree. @@ -31,7 +30,8 @@ the anchor is a heading slug (or an ``/`id`) in the target, and that heading is not duplicated there. A duplicated heading is what silently moves an anchor to `-1`. A duplicate no link points at is left alone, since there is nothing for it to move. - R5 No template placeholder left: `rel-`, ``, ``. + R5 No template placeholder left: `rel-` or ``. A bare `` is allowed: it is + used deliberately in prose that survives the cut, such as a `-.jar.sha256` file pattern. NEXT.md (the version-less draft, renamed to rel-.md at the cut) N1 As R1. @@ -43,8 +43,11 @@ R3, R4 and N3 run against the working tree, which is the tree being tagged both in CI on the cut PR and in release.yml at the tag: no git, no tag peeling, no network. Cross-repo paths and anchors get R2 only, since that -tree is not checked out, and so do SHA-pinned links. Whether URLs resolve over the network is deliberately not -checked: before the tag is pushed, every correctly pinned link 404s. +tree is not checked out, and so do SHA-pinned links. + +Deliberately not checked: whether URLs resolve over the network (before the tag is pushed, every correctly pinned +link 404s); and `releases/tag/...` and `compare/...` links, which R2 leaves alone because linking an earlier +release, or comparing against one, is legitimate. WHICH NOTES ARE ALREADY RELEASED. The rel-*.md with the highest X.Y.Z in its directory is the release being cut (or, between cuts, the latest one) and gets every rule. Every other rel-*.md is already released and gets the @@ -83,9 +86,11 @@ rule that has quietly stopped matching fails loudly instead of passing everything. Usage: - python .dev/tools/check-release-notes.py [FILE ...] + python [FILE ...] -With no arguments, checks every /rel-*.md, and NEXT.md if present. Stdlib only. Exits 0 if all pass, +The script finds the repository root by walking up from its own location to the first directory containing `.git` +(a directory, or a file in a worktree), so it does not depend on where in the repository it lives. With no +arguments, checks every /rel-*.md, and NEXT.md if present. Stdlib only. Exits 0 if all pass, 1 otherwise. """ @@ -124,7 +129,29 @@ # END OF REPO-SPECIFIC CONFIGURATION. Everything below is identical in every copy. # ================================================================================================================== -REPO_ROOT = Path(__file__).resolve().parents[2] + +class RepoRootNotFound(RuntimeError): + pass + + +def find_repo_root(start: Path, stop: Path | None = None) -> Path: + """The first directory at or above `start` containing `.git` -- a directory, or a file in a git worktree. + `stop`, if given, is the last directory examined.""" + here = start.resolve() + if here.is_file(): + here = here.parent + for candidate in (here, *here.parents): + if (candidate / ".git").exists(): + return candidate + if stop is not None and candidate == stop.resolve(): + break + raise RepoRootNotFound(f"no directory containing .git at or above {start}") + + +try: + REPO_ROOT = find_repo_root(Path(__file__)) +except RepoRootNotFound as error: + sys.exit(f"FAIL: cannot find the repository root: {error}; run this script from inside a git checkout") NOTES_DIR = REPO_ROOT / NOTES_DIR_IN_REPO NEXT_NAME = "NEXT.md" OWNER = "osprey-dcs" @@ -328,7 +355,8 @@ def exists_exactly(root: Path, rel: str, kind: str) -> bool: # Link rules R1-R4 (rel-X.Y.Z.md) and N1-N3 (NEXT.md); placeholder rule R5 # ------------------------------------------------------------------------------------------------------------------ -PLACEHOLDER_RE = re.compile(r"rel-||") +# A bare `` is deliberately absent: it appears in prose that is meant to survive the cut. +PLACEHOLDER_RE = re.compile(r"rel-|") def check_links(name: str, text: str, *, tag: str | None, full: bool, root: Path, cfg: Config) -> list[str]: @@ -412,7 +440,8 @@ def check_placeholders(name: str, text: str) -> list[str]: CHANGELOG_RE = re.compile(r"^\*\*Full Changelog\*\*:\s*(\S+)\s*$", re.MULTILINE) # What each `sigstore verify identity` must name, as (description, predicate over its file operands). Every -# release signs all three, and verifying only the wheel was exactly the shape rel-1.16.0's page first shipped with. +# release signs all three, and verifying only the wheel is the mistake this catches: easy to copy, and it leaves the +# sdist and SHA256SUMS unverified. REQUIRED_OPERANDS = [ ("the wheel", lambda arg: arg.endswith(".whl")), ("the sdist", lambda arg: arg.endswith(".tar.gz")), @@ -633,8 +662,10 @@ def _make_tree(root: Path) -> None: '\n\n```\n## Not a heading\n```\n', encoding="utf-8", ) - (root / "doc" / "guide.md").write_text("# Guide\n\n## Internal: the `_dispatch` refactor (Issue #14)\n") - (root / "README.env").write_text("verification reference\n") + (root / "doc" / "guide.md").write_text( + "# Guide\n\n## Internal: the `_dispatch` refactor (Issue #14)\n", encoding="utf-8" + ) + (root / "README.env").write_text("verification reference\n", encoding="utf-8") # Links that must pass in the newest notes (rel-2.1.0), against the tree from _make_tree. @@ -650,6 +681,8 @@ def _make_tree(root: Path) -> None: - [added later]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/not/in/this/tree.md) - [issue]({_PY}/issues/7), [mail](mailto:x@example.org), [up](#installing), <{_PY}/pull/9> - quoted, not linked: `[x](../../README.md)` and `{_PY}/blob/main/README.md` +- a bare version placeholder is prose, not a leftover: `dp-service-.jar.sha256` +- earlier releases may be linked: {_PY}/releases/tag/rel-1.0.0 and {_PY}/compare/rel-1.0.0...rel-2.0.0 ```markdown See [the README](../../README.md#x) or [on main]({_PY}/blob/main/README.md). @@ -716,6 +749,7 @@ def draft(text: str) -> list[str]: "R2 a short SHA": f"[s]({_PY}/blob/0123456/README.md)", "R5 a leftover rel-": "```\n--certificate-identity '...@refs/tags/rel-'\n```", "R5 a leftover ": f"`{_PY}/compare/...rel-2.1.0`", + "R5 a leftover rel- in prose": "Install rel- from the release page.", } tree_rules = { "R3 a missing path": f"[p]({_PY}/blob/{_TAG}/doc/missing.md)", @@ -759,6 +793,35 @@ def draft(text: str) -> list[str]: return failures +def _self_test_repo_root(tmp: Path) -> list[str]: + """The root is the nearest ancestor holding `.git`, as a directory (a clone) or a file (a worktree).""" + failures: list[str] = [] + for kind in ("dir", "file"): + top = tmp / f"root-{kind}" + script = top / "a" / "b" / "c" / "check.py" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + if kind == "dir": + (top / ".git").mkdir() + else: + (top / ".git").write_text("gitdir: /elsewhere\n", encoding="utf-8") + try: + found = find_repo_root(script, stop=tmp) + except RepoRootNotFound as error: + failures.append(f"a .git {kind} was not found: {error}") + continue + if found != top.resolve(): + failures.append(f"a .git {kind} at {top} was found at {found}") + bare = tmp / "no-git" / "x" + bare.mkdir(parents=True) + try: + found = find_repo_root(bare, stop=tmp) + failures.append(f"a tree with no .git produced a repository root, {found}") + except RepoRootNotFound: + pass + return failures + + def _self_test_released_rule() -> list[str]: names = ["rel-1.9.0.md", "rel-1.16.0.md", "rel-1.10.2.md", "NEXT.md", "README.md"] newest = newest_release(names) @@ -900,8 +963,10 @@ def self_test() -> list[str]: failures = _self_test_released_rule() + _self_test_sigstore_python() + _self_test_cosign() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - _make_tree(root) - failures += _self_test_links(root) + (root / "tree").mkdir() + _make_tree(root / "tree") + failures += _self_test_links(root / "tree") + failures += _self_test_repo_root(root) return failures diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f246844..379bd5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: # here so a mistake fails the notes PR, not the tag push. doc/release-notes/NEXT.md, the # version-less draft renamed at the cut, is checked the other way round: it must carry none # of those tag-bearing parts, since any it has guesses a version not yet decided. - run: python .dev/tools/check-release-notes.py + run: python .github/scripts/check-release-notes.py build: name: Build distributions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd7782f..1bb39b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: echo "::error::Add the notes for ${{ steps.version.outputs.version }} and retag once they are on the tagged commit." exit 1 fi - python .dev/tools/check-release-notes.py "$NOTES" + python .github/scripts/check-release-notes.py "$NOTES" echo "notes=$NOTES" >> "$GITHUB_OUTPUT" - name: Build wheel and sdist diff --git a/CLAUDE.md b/CLAUDE.md index 6b86e96..58fd0a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ diffs the live body against the file. So each notes file must carry, by hand: - a `**Full Changelog**: https://github.com/osprey-dcs/dp-python-lib/compare/rel-...rel-` line, standing in for GitHub's generated commit list. -`.dev/tools/check-release-notes.py` enforces both, and above all the tag in the identity: the +`.github/scripts/check-release-notes.py` enforces both, and above all the tag in the identity: the section is usually copied from the previous release, and a stale tag makes `sigstore verify` reject every genuine artifact. It also requires each verify command to name all three files (the backported rel-1.16.0 section first verified the wheel only), and the changelog link to end at @@ -166,14 +166,18 @@ previous notes is stale. The one exception is a full 40-character commit SHA, f not exist at the tag; rel-1.16.0's `README.env` link is pinned that way, because the verification section was added after the release and `blob/rel-1.16.0/README.env` is a 404. Paths and `#anchors` into this repo must exist in the working tree, and an anchor may not point at a duplicated heading. -No `rel-`, ``, or `` placeholder may be left. `NEXT.md` is the other way +No `rel-` or `` placeholder may be left; a bare `` is allowed, since +it appears deliberately in prose that survives the cut (a `-.jar.sha256` pattern, say). `NEXT.md` is the other way round: links stay on `main`, and their paths and anchors are checked, so renaming a heading it links to fails the renaming PR. **Which notes count as already released:** every `rel-*.md` except the highest version. Those get only the form rules (relative links, pinning, placeholders), since checking an immutable file against today's tree would fail it the first time a heading is renamed. -The script is copied verbatim into dp-grpc, dp-service, dp-desktop-app, and data-platform, which -differ only in the configuration block at its top (repository name and which signing-identity rules -apply); it already carries their cosign rules, self-tested, so a fix here is ported by copying the file. +The script lives at `.github/scripts/check-release-notes.py` in all five repos (it finds the +repository root by walking up to the nearest `.git`, so its depth does not matter), and is copied +verbatim into dp-grpc, dp-service, dp-desktop-app, and data-platform, which differ only in the +configuration block at its top (repository name and which signing-identity rules apply). It already +carries their cosign rules, self-tested, so a fix here is ported by copying the file. Unlike `.dev/`, +`.github/scripts/` is not gitignored, so ruff lints and formats it like any other source file. **Never edit a release page by hand.** Fix the notes file by PR, then republish with `gh release edit rel-X.Y.Z --notes-file doc/release-notes/rel-X.Y.Z.md`. With the file as the diff --git a/doc/release-notes/NEXT.md b/doc/release-notes/NEXT.md index b6eb73a..f71b86a 100644 --- a/doc/release-notes/NEXT.md +++ b/doc/release-notes/NEXT.md @@ -13,7 +13,7 @@ filename or in its prose. `release.yml` resolves the notes path strictly from t (`doc/release-notes/${GITHUB_REF_NAME}.md`), so a file committed under a guessed version is both stranded and a failed release-notes check on the tag that does ship. Past versions are named freely where they are the point — "since 1.16.0" is a durable fact about what shipped, not a guess -about what is about to. `.dev/tools/check-release-notes.py` enforces the parts that would carry the +about what is about to. `.github/scripts/check-release-notes.py` enforces the parts that would carry the new tag: this file may not contain a verification section, a `sigstore verify identity` command, a signing identity, or a Full Changelog line. Those are written at the cut. Naming them in prose is fine, as the checklist below does, but put the name in backticks: the checker treats an unquoted @@ -302,13 +302,13 @@ When the version is known and the release is being cut: to the tag it keeps describing the content this release actually shipped. Don't hunt for them by eye: step 8 lists every one you missed, and any stale `rel-*` tag copied from older notes. 7. **Delete this "Cutting the release" section** and update Contents. -8. **Run `python .dev/tools/check-release-notes.py`** and fix everything it lists; CI runs it on the - PR too. For the new file it fails on a relative link; a link into any osprey-dcs repo not - pinned to `rel-`; a path or `#anchor` into this repo that is missing from the tree being - tagged, or that points at a duplicated heading; a leftover `rel-`, ``, or - ``; a missing verification section; a stale tag in the identity or the changelog - link; or a verify command that skips a file. The rules are in the script's docstring - (osprey-dcs/data-platform#98). +8. **Run `python .github/scripts/check-release-notes.py`** and fix everything it lists; CI runs + it on the PR too. For the new file it fails on a relative link; a link into any osprey-dcs repo + not pinned to `rel-`; a path or `#anchor` into this repo that is missing from the tree + being tagged, or that points at a duplicated heading; a leftover `rel-` or `` + (a bare `` is allowed); a missing verification section; a stale tag in the identity or + the changelog link; or a verify command that skips a file. The rules are in the script's + docstring (osprey-dcs/data-platform#98). 9. **Start a fresh `NEXT.md`** for the following cycle. Steps 1, 2, and 7 have moved, rewritten, and deleted the text it needs, so recover it from `main`: `git show main:doc/release-notes/NEXT.md > doc/release-notes/NEXT.md`, then delete every ticket diff --git a/plan/tickets/70/plan.md b/plan/tickets/70/plan.md index b200fbf..3f75a3a 100644 --- a/plan/tickets/70/plan.md +++ b/plan/tickets/70/plan.md @@ -5,8 +5,9 @@ which holds the rules (R1–R5, N1–N3) and their rationale; they are not repea ## Overview -`.dev/tools/check-release-notes.py` gains #98's link and placeholder rules, and becomes the copy the -other four repos take verbatim, changing only a configuration block. rel-1.16.0's one `main`-pinned +The release-notes checker gains #98's link and placeholder rules, moves from +`.dev/tools/check-release-notes.py` to `.github/scripts/check-release-notes.py`, and becomes the copy +the other four repos take verbatim, changing only a configuration block. rel-1.16.0's one `main`-pinned link is fixed in the file and, after merge, on the published release page. For whoever cuts releases and reviews PRs that touch release notes or headings they link to. @@ -53,9 +54,20 @@ configurations in every copy, so a port is configuration only. **D5 — Bare URLs and autolinks count for R2–R4**; GitHub links them. R1 looks only at inline and reference-definition targets. Paths are matched case-sensitively per component, since macOS is not. +**D6 — Decisions made after the ports reported back (2026-10-01).** +- *Location:* `.github/scripts/check-release-notes.py` in all five repos. The script finds the + repository root by walking up to the first directory containing `.git` (directory or worktree + file) rather than a fixed `parents[N]`, and fails clearly when there is none. The path is no + longer gitignored, so ruff covers it by default. Plans for earlier tickets (#19, #26, #56, #58) + keep the old path: they record where the file was when they shipped. +- *R5 is narrowed* to `rel-` and ``. A bare `` is allowed: three repos + use it deliberately in prose that survives the cut (`dp-service-.jar.sha256`). +- *Repo-neutral text:* nothing outside the configuration block names this repo's paths or history. +- R2 does not look at `releases/tag/…` or `compare/…` links: linking an earlier release is legitimate. + ## Implementation tasks -- `.dev/tools/check-release-notes.py`: the rules, the configuration block, and self-test cases for +- `.github/scripts/check-release-notes.py` (moved from `.dev/tools/`; CI and `release.yml` updated): the rules, the configuration block, and self-test cases for each rule (good and bad, newest vs released, draft), all three cosign profiles, and the released rule's numeric ordering. Verified by mutation: disabling any rule fails the self-test. - `doc/release-notes/rel-1.16.0.md:410`: SHA pin (D3). From 6bfbd1002f9ef3e63fd7bc3741fe129c4a7a6bd1 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 14:05:44 -0600 Subject: [PATCH 5/7] Release notes checker: close three link-rule gaps found in review (#70) - A reference definition whose destination is on the next line (`[x]:` then ` ../README.md`) is valid CommonMark, and slipped past R1/N1. The pattern now accepts one line break before the target. - A same-document `#L` anchor passed R4, but a release body has no line anchors; they are now accepted only on blob links. - An `http://` github.com or raw link was not parsed as a repo link, so a `main`-pinned one bypassed R2/N2. Both patterns accept http too. Each gap has a self-test case that fails if the fix is reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- .github/scripts/check-release-notes.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/scripts/check-release-notes.py b/.github/scripts/check-release-notes.py index b59fe51..dc10c73 100755 --- a/.github/scripts/check-release-notes.py +++ b/.github/scripts/check-release-notes.py @@ -29,7 +29,8 @@ R4 For tag-pinned links into this repo's .md files with a `#anchor`, and for same-document `#anchor` links, the anchor is a heading slug (or an ``/`id`) in the target, and that heading is not duplicated there. A duplicated heading is what silently moves an anchor to `-1`. A duplicate no link points at is - left alone, since there is nothing for it to move. + left alone, since there is nothing for it to move. A `#L` line anchor is accepted on a blob link and + rejected on a same-document link, since a release body has no line anchors. R5 No template placeholder left: `rel-` or ``. A bare `` is allowed: it is used deliberately in prose that survives the cut, such as a `-.jar.sha256` file pattern. @@ -241,9 +242,9 @@ def strip_code(text: str) -> str: # Inline link or image: `](target`, optionally . Reference definition: `[label]: target` at the start of -# a line (a footnote definition, `[^1]:`, is not a link). +# a line, the target on the same line or the next (a footnote definition, `[^1]:`, is not a link). INLINE_TARGET_RE = re.compile(r"\]\(\s*(<[^>\n]*>|[^\s)]+)") -REFDEF_TARGET_RE = re.compile(r"^ {0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(<[^>\n]*>|\S+)", re.MULTILINE) +REFDEF_TARGET_RE = re.compile(r"^ {0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(?:\n[ \t]*)?(<[^>\n]*>|\S+)", re.MULTILINE) # Any absolute URL, wherever it appears: GitHub links bare URLs too, so R2 must see them. URL_RE = re.compile(r"https?://[^\s<>()\[\]\"'`]+") ALLOWED_TARGET_RE = re.compile(r"^(?:https?:|mailto:|#)", re.IGNORECASE) @@ -310,10 +311,10 @@ class RepoLink: BLOB_RE = re.compile( - rf"^https://(?:www\.)?github\.com/{OWNER}/([\w.-]+)/(blob|tree)/([^/?#]+)(/[^?#]*)?(\?[^#]*)?(#.*)?$" + rf"^https?://(?:www\.)?github\.com/{OWNER}/([\w.-]+)/(blob|tree)/([^/?#]+)(/[^?#]*)?(\?[^#]*)?(#.*)?$" ) RAW_RE = re.compile( - rf"^https://raw\.githubusercontent\.com/{OWNER}/([\w.-]+)/(?:refs/(?:heads|tags)/)?([^/?#]+)(/[^?#]*)?" + rf"^https?://raw\.githubusercontent\.com/{OWNER}/([\w.-]+)/(?:refs/(?:heads|tags)/)?([^/?#]+)(/[^?#]*)?" r"(\?[^#]*)?(#.*)?$" ) FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") @@ -374,7 +375,8 @@ def where(pos: int) -> str: return f"{name}:{line_of(stripped, pos)}" def check_anchor(pos: int, anchor: str, anchors: tuple[set[str], set[str]], target: str, rule: str) -> None: - if LINE_ANCHOR_RE.match(anchor): + # GitHub's #L line anchors exist on a blob page, not in a release body, so `target` "this file" gets none. + if target != "this file" and LINE_ANCHOR_RE.match(anchor): return known, duplicated = anchors if anchor not in known: @@ -676,6 +678,7 @@ def _make_tree(root: Path) -> None: - [config]({_PY}/blob/{_TAG}/README.md#configuration-priority) and [guide][guide] - [dispatch]({_PY}/blob/{_TAG}/doc/guide.md#internal-the-_dispatch-refactor-issue-14) - [html anchor]({_PY}/blob/{_TAG}/README.md#pinned), [env]({_PY}/blob/{_TAG}/README.env) +- [lines]({_PY}/blob/{_TAG}/README.md?plain=1#L3-L5), [guide on the next line][guide-next-line] - [doc dir]({_PY}/tree/{_TAG}/doc), raw: https://raw.githubusercontent.com/osprey-dcs/dp-python-lib/{_TAG}/README.env - [dp-grpc notes](https://github.com/osprey-dcs/dp-grpc/blob/{_TAG}/doc/release-notes/{_TAG}.md#anything) - [added later]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/not/in/this/tree.md) @@ -690,6 +693,8 @@ def _make_tree(root: Path) -> None: ``` [guide]: {_PY}/blob/{_TAG}/doc/guide.md +[guide-next-line]: + {_PY}/blob/{_TAG}/doc/guide.md """ # A NEXT.md that must pass: links on main, and a checklist quoting everything the release rules forbid. @@ -738,6 +743,7 @@ def draft(text: str) -> list[str]: "R1 a ../ relative link": "[r](../../README.md#x)", "R1 a bare relative path": "[r](doc/guide.md)", "R1 a relative reference definition": "[r]: doc/guide.md", + "R1 a relative reference definition on the next line": "[r]:\n ../../README.md", "R1 a relative image": "![i](img/x.png)", "R2 a link left on main": f"[m]({_PY}/blob/main/README.md)", "R2 a main-pinned reference definition": f"[readme-env]: {_PY}/blob/main/README.env", @@ -746,6 +752,7 @@ def draft(text: str) -> list[str]: "R2 a tree link on main": f"[t]({_PY}/tree/main/doc)", "R2 a raw link on main": "https://raw.githubusercontent.com/osprey-dcs/dp-service/main/README.md", "R2 a bare URL on main": f"See {_PY}/blob/main/README.md.", + "R2 an http:// link on main": "[h](http://github.com/osprey-dcs/dp-python-lib/blob/main/README.md)", "R2 a short SHA": f"[s]({_PY}/blob/0123456/README.md)", "R5 a leftover rel-": "```\n--certificate-identity '...@refs/tags/rel-'\n```", "R5 a leftover ": f"`{_PY}/compare/...rel-2.1.0`", @@ -760,6 +767,7 @@ def draft(text: str) -> list[str]: "R4 an anchor to a duplicated heading": f"[d]({_PY}/blob/{_TAG}/README.md#methods)", "R4 an anchor to a duplicate's -1": f"[d]({_PY}/blob/{_TAG}/README.md#methods-1)", "R4 a missing same-document anchor": "[c](#contents)", + "R4 a same-document line anchor": "[l](#L12)", } for description, extra in {**form_rules, **tree_rules}.items(): if not release(f"{_GOOD_LINKS}\n{extra}\n"): @@ -774,6 +782,7 @@ def draft(text: str) -> list[str]: for description, extra in { "N1 a relative link": "[r](../README.md)", "N1 a relative reference definition": "[r]: ./doc/guide.md", + "N1 a relative reference definition on the next line": "[r]:\n./doc/guide.md", "N2 a rel- tag": f"[t]({_PY}/blob/rel-2.1.0/README.md)", "N2 a rel- reference definition into another repo": "[g]: https://github.com/osprey-dcs/dp-grpc/blob/rel-2.1.0/x", "N2 a commit SHA": f"[s]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/README.md)", From 7d2afdc88eb5167cb19b8ecd5de5659fc6a2ce4d Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 14:08:28 -0600 Subject: [PATCH 6/7] Release notes checker: fixes from the port PRs' reviews (#70) Found by Copilot on the port PRs (dp-desktop-app#52, dp-grpc#169), and folded into the reference copy so all five copies stay identical: - R2/N2 apply only to the five lockstep repos (LOCKSTEP_REPOS, outside the per-repo block). A link into another osprey-dcs repo, such as dp-support, has its own release cycle and is no longer checked. - An ``/`id` quoted in a fenced block or code span no longer counts as an anchor for R4/N3. - An option value on the line after a backslash continuation (`--certificate-identity \` then the value) is read as the value, for the cosign and sigstore identity and issuer rules, instead of reporting the backslash as a wrong identity. - The docstring lists blob line anchors (`#L`) as not range-checked. Each fix has self-test cases that fail if it is reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- .github/scripts/check-release-notes.py | 80 +++++++++++++++++++++----- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/.github/scripts/check-release-notes.py b/.github/scripts/check-release-notes.py index dc10c73..e971c81 100755 --- a/.github/scripts/check-release-notes.py +++ b/.github/scripts/check-release-notes.py @@ -21,6 +21,7 @@ `#anchor` fails (`../../README.md`, `doc/x.md`). R2 Every github.com/osprey-dcs//(blob|tree)//... and raw.githubusercontent.com/osprey-dcs// /... link, into any of the five repos, has equal to this file's tag (they release in lockstep). + Links into the org's other repos, which do not, are not checked. Exception: a full 40-character commit SHA, for a target that did not exist at the tag -- a section added to the notes after the release, linking a file added after the tag. A SHA cannot drift; `main`, a short SHA, or any other tag still fails. @@ -47,8 +48,8 @@ tree is not checked out, and so do SHA-pinned links. Deliberately not checked: whether URLs resolve over the network (before the tag is pushed, every correctly pinned -link 404s); and `releases/tag/...` and `compare/...` links, which R2 leaves alone because linking an earlier -release, or comparing against one, is legitimate. +link 404s); `releases/tag/...` and `compare/...` links, which R2 leaves alone because linking an earlier +release, or comparing against one, is legitimate; and whether a blob link's `#L` line anchor is in range. WHICH NOTES ARE ALREADY RELEASED. The rel-*.md with the highest X.Y.Z in its directory is the release being cut (or, between cuts, the latest one) and gets every rule. Every other rel-*.md is already released and gets the @@ -109,6 +110,10 @@ # Shared by every copy (the self-test uses it), and defined here so dp-grpc's configuration below can name it. DP_GRPC_IDENTITY_REGEXP = r"^https://github.com/osprey-dcs/dp-grpc/\.github/workflows/release\.yml@refs/tags/rel-" +# The five repos that release in lockstep, and so the only ones R2/N2 apply to. The org's other repos (dp-support, +# dp-jal, ...) have their own release cycles, so a link into one of them is left alone. The same in every copy. +LOCKSTEP_REPOS = frozenset({"dp-grpc", "dp-service", "dp-desktop-app", "dp-python-lib", "data-platform"}) + # ================================================================================================================== # REPO-SPECIFIC CONFIGURATION -- the only lines that differ between the five copies of this script. # @@ -292,7 +297,7 @@ def heading_anchors(text: str) -> tuple[set[str], set[str]]: anchors.add(slug) by_base.setdefault(base, []).append(slug) duplicated = {slug for slugs in by_base.values() if len(slugs) > 1 for slug in slugs} - anchors.update(HTML_ANCHOR_RE.findall(text)) + anchors.update(HTML_ANCHOR_RE.findall(strip_code(text))) # an `` quoted in code is not an anchor return anchors, duplicated @@ -321,7 +326,8 @@ class RepoLink: def parse_repo_link(url: str) -> RepoLink | None: - """The parts of an osprey-dcs blob/tree/raw URL, or None for any other URL (issues, PRs, compare, ...).""" + """The parts of a blob/tree/raw URL into one of the five lockstep repos, or None for any other URL (issues, PRs, + compare, another osprey-dcs repo, ...).""" blob = BLOB_RE.match(url) if blob: repo, kind, ref, path, _query, anchor = blob.groups() @@ -331,6 +337,8 @@ def parse_repo_link(url: str) -> RepoLink | None: return None repo, ref, path, _query, anchor = raw.groups() kind = "raw" + if repo not in LOCKSTEP_REPOS: + return None return RepoLink( repo=repo, kind=kind, @@ -435,8 +443,10 @@ def check_placeholders(name: str, text: str) -> list[str]: VERIFY_HEADING_RE = re.compile(r"^## Verifying these artifacts\s*$", re.MULTILINE) # The whole command, backslash continuations included, up to the first line that does not continue. SIGSTORE_VERIFY_RE = re.compile(r"\bsigstore\s+verify\s+identity\b(?:[^\n]*\\\n)*[^\n]*") -SIGSTORE_IDENTITY_RE = re.compile(r"--cert-identity[ =]\"?([^\"\s]+)\"?") -SIGSTORE_ISSUER_RE = re.compile(r"--cert-oidc-issuer[ =]\"?([^\"\s]+)\"?") +# Between an option and its value: `=`, spaces, or a backslash-newline continuation. +OPTION_SEP = r"(?:=|[ \t]+\\\n[ \t]*|[ \t]+)" +SIGSTORE_IDENTITY_RE = re.compile(rf"--cert-identity{OPTION_SEP}\"?([^\"\s]+)\"?") +SIGSTORE_ISSUER_RE = re.compile(rf"--cert-oidc-issuer{OPTION_SEP}\"?([^\"\s]+)\"?") # A command as written in a code block, rather than named in prose. SIGSTORE_VERIFY_COMMAND_RE = re.compile(r"^[ \t]*sigstore\s+verify\s+identity\b", re.MULTILINE) CHANGELOG_RE = re.compile(r"^\*\*Full Changelog\*\*:\s*(\S+)\s*$", re.MULTILINE) @@ -548,10 +558,11 @@ def check_next_sigstore_python(name: str, text: str) -> list[str]: def option_values(option: str, text: str) -> list[tuple[int, str]]: - """(offset, value) of every `option VALUE` / `option=VALUE`, the value optionally single- or double-quoted. + """(offset, value) of every `option VALUE` / `option=VALUE`, the value optionally single- or double-quoted and + optionally on the next line after a backslash continuation. `--certificate-identity` does not match `--certificate-identity-regexp`, nor prose naming the flag in backticks.""" - pattern = re.compile(rf"{re.escape(option)}[ =](?:'([^'\n]*)'|\"([^\"\n]*)\"|([^\s'\"]+))") + pattern = re.compile(rf"{re.escape(option)}{OPTION_SEP}(?:'([^'\n]*)'|\"([^\"\n]*)\"|([^\s'\"]+))") return [(m.start(), next(g for g in m.groups() if g is not None)) for m in pattern.finditer(text)] @@ -661,7 +672,7 @@ def _make_tree(root: Path) -> None: (root / "doc").mkdir() (root / "README.md").write_text( "# Project\n\n## Configuration priority\n\n### Methods\n\n### Methods\n\n" - '\n\n```\n## Not a heading\n```\n', + '\n\n```\n## Not a heading\n\n```\n\nSee ``.\n', encoding="utf-8", ) (root / "doc" / "guide.md").write_text( @@ -682,6 +693,8 @@ def _make_tree(root: Path) -> None: - [doc dir]({_PY}/tree/{_TAG}/doc), raw: https://raw.githubusercontent.com/osprey-dcs/dp-python-lib/{_TAG}/README.env - [dp-grpc notes](https://github.com/osprey-dcs/dp-grpc/blob/{_TAG}/doc/release-notes/{_TAG}.md#anything) - [added later]({_PY}/blob/0123456789abcdef0123456789abcdef01234567/not/in/this/tree.md) +- not in lockstep, so unchecked: https://github.com/osprey-dcs/dp-support/blob/main/README.md and + https://raw.githubusercontent.com/osprey-dcs/dp-grpc-extras/rel-1.0.0/x.md - [issue]({_PY}/issues/7), [mail](mailto:x@example.org), [up](#installing), <{_PY}/pull/9> - quoted, not linked: `[x](../../README.md)` and `{_PY}/blob/main/README.md` - a bare version placeholder is prose, not a leftover: `dp-service-.jar.sha256` @@ -702,6 +715,7 @@ def _make_tree(root: Path) -> None: - [config]({_PY}/blob/main/README.md#configuration-priority), [guide][guide], [down](#cutting-the-release) - [dp-grpc](https://github.com/osprey-dcs/dp-grpc/blob/main/README.md) +- not in lockstep, so unchecked: [dp-support](https://github.com/osprey-dcs/dp-support/blob/rel-1.0.0/README.md) ## Cutting the release @@ -749,6 +763,8 @@ def draft(text: str) -> list[str]: "R2 a main-pinned reference definition": f"[readme-env]: {_PY}/blob/main/README.env", "R2 a stale tag": f"[s]({_PY}/blob/rel-2.0.0/README.md)", "R2 a stale tag into another repo": "[g](https://github.com/osprey-dcs/dp-grpc/blob/rel-2.0.0/README.md)", + "R2 a link left on main in data-platform": "[d](https://github.com/osprey-dcs/data-platform/blob/main/x.md)", + "R2 a link left on main in dp-desktop-app": "[d](https://github.com/osprey-dcs/dp-desktop-app/tree/main/doc)", "R2 a tree link on main": f"[t]({_PY}/tree/main/doc)", "R2 a raw link on main": "https://raw.githubusercontent.com/osprey-dcs/dp-service/main/README.md", "R2 a bare URL on main": f"See {_PY}/blob/main/README.md.", @@ -764,6 +780,8 @@ def draft(text: str) -> list[str]: "R3 a blob link to a directory": f"[p]({_PY}/blob/{_TAG}/doc)", "R4 a missing anchor": f"[a]({_PY}/blob/{_TAG}/README.md#configuration)", "R4 an anchor only inside a code block": f"[a]({_PY}/blob/{_TAG}/README.md#not-a-heading)", + "R4 an HTML anchor only inside a code block": f"[a]({_PY}/blob/{_TAG}/README.md#quoted)", + "R4 an HTML anchor only inside a code span": f"[a]({_PY}/blob/{_TAG}/README.md#spanned)", "R4 an anchor to a duplicated heading": f"[d]({_PY}/blob/{_TAG}/README.md#methods)", "R4 an anchor to a duplicate's -1": f"[d]({_PY}/blob/{_TAG}/README.md#methods-1)", "R4 a missing same-document anchor": "[c](#contents)", @@ -842,9 +860,11 @@ def _self_test_released_rule() -> list[str]: def _self_test_sigstore_python() -> list[str]: failures: list[str] = [] cfg = replace(CONFIG, repository="osprey-dcs/dp-python-lib") - good = check_sigstore_python("", _TAG, _sigstore_sample(), cfg) - if good: - failures.append("a correct sigstore sample was rejected:\n " + "\n ".join(good)) + wrapped = _sigstore_sample().replace('--cert-identity "', '--cert-identity \\\n "') + for description, notes in {"a correct sigstore sample": _sigstore_sample(), "a wrapped one": wrapped}.items(): + good = check_sigstore_python("", _TAG, notes, cfg) + if good: + failures.append(f"{description} was rejected:\n " + "\n ".join(good)) for description, notes in { "a stale --cert-identity tag": _sigstore_sample(identity_tag="rel-2.0.0"), "a verify of the wheel only": _sigstore_sample(files="dp_python_lib-*.whl"), @@ -857,6 +877,9 @@ def _self_test_sigstore_python() -> list[str]: "dp-python-lib/compare", "dp-grpc/compare" ), "no verification heading": _sigstore_sample().replace("## Verifying these artifacts", "## Verification"), + "a stale --cert-identity tag on a continuation line": _sigstore_sample(identity_tag="rel-2.0.0").replace( + '--cert-identity "', '--cert-identity \\\n "' + ), }.items(): if not check_sigstore_python(f"<{description}>", _TAG, notes, cfg): failures.append(f"{description} was accepted") @@ -898,15 +921,23 @@ def ident(repo: str, workflow: str = "release.yml", tag: str = _TAG) -> str: f" ghcr.io/osprey-dcs/dp-service:{_TAG}\n" "Prose naming `--certificate-identity` is not an identity.\n" ) + + def wrapped(option: str, value: str) -> str: + """A verify command with each option's value on the line after it.""" + return ( + f"cosign verify-blob \\\n {option} \\\n '{value}' \\\n" + f" --certificate-oidc-issuer \\\n {OIDC_ISSUER} SHA256SUMS\n" + ) + desktop_id = ident("dp-desktop-app") desktop_good = ( f"cosign verify-blob --certificate-identity '{desktop_id}' {issuer} SHA256SUMS\n" f'cosign verify-blob --certificate-identity "{desktop_id}" {issuer} ' - "--certificate-github-workflow-trigger push SHA256SUMS\n" + "--certificate-github-workflow-trigger push SHA256SUMS\n" + wrapped("--certificate-identity", desktop_id) ) grpc_good = ( f"cosign verify-blob \\\n --certificate-identity-regexp '{DP_GRPC_IDENTITY_REGEXP}' \\\n" - f" {issuer} \\\n SHA256SUMS\n" + f" {issuer} \\\n SHA256SUMS\n" + wrapped("--certificate-identity-regexp", DP_GRPC_IDENTITY_REGEXP) ) for description, cfg, text in [ ("dp-service", service, service_good), @@ -958,6 +989,27 @@ def ident(repo: str, workflow: str = "release.yml", tag: str = _TAG) -> str: desktop, desktop_good.replace("release.yml", "release-image.yml", 1), ), + ( + "dp-desktop-app: a stale identity on a continuation line", + desktop, + desktop_good.replace( + wrapped("--certificate-identity", desktop_id), + wrapped("--certificate-identity", ident("dp-desktop-app", tag=stale)), + ), + ), + ( + "dp-desktop-app: a wrong issuer on a continuation line", + desktop, + desktop_good.replace(f"\\\n {OIDC_ISSUER}", "\\\n https://accounts.google.com"), + ), + ( + "dp-grpc: a loosened regexp on a continuation line", + grpc, + grpc_good.replace( + wrapped("--certificate-identity-regexp", DP_GRPC_IDENTITY_REGEXP), + wrapped("--certificate-identity-regexp", ".*"), + ), + ), ("dp-grpc: an unanchored regexp", grpc, grpc_good.replace("'^https", "'https")), ("dp-grpc: a regexp for any workflow", grpc, grpc_good.replace(r"release\.yml", ".*")), ("dp-grpc: a regexp without the rel- prefix", grpc, grpc_good.replace("refs/tags/rel-'", "refs/tags/'")), From 11d4a52bcd77ade1cfad5a02b6d7eeae159634c8 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Thu, 1 Oct 2026 14:13:52 -0600 Subject: [PATCH 7/7] Release notes checker: ignore sentence punctuation after an image tag (#70) Found by Copilot on osprey-dcs/dp-service#306. An unquoted image in prose ("pull ghcr.io/osprey-dcs/dp-service:rel-2.1.0.") was read as tag `rel-2.1.0.` and failed correct notes; a trailing comma did the same. Trailing `.,;:!?` is now stripped from the captured tag, as urls() does, so a `rel-2.1.0-rc1` tag is still read whole. Self-tests cover a good tag followed by a period and by a comma, and an -rc1 tag before a period. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012nsCzraPATf4LCKVyUStfd --- .github/scripts/check-release-notes.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/scripts/check-release-notes.py b/.github/scripts/check-release-notes.py index e971c81..2815017 100755 --- a/.github/scripts/check-release-notes.py +++ b/.github/scripts/check-release-notes.py @@ -579,9 +579,10 @@ def check_cosign(name: str, tag: str, text: str, cfg: Config) -> list[str]: ) if cfg.cosign_image: for m in re.finditer(rf"{re.escape(cfg.cosign_image)}:(rel-[^\s'\"`)]*)", text): - if m.group(1) != tag: + image_tag = m.group(1).rstrip(".,;:!?") # sentence punctuation after an unquoted image in prose + if image_tag != tag: problems.append( - f"{name}:{line_of(text, m.start())}: image {cfg.cosign_image}:{m.group(1)}, expected :{tag}" + f"{name}:{line_of(text, m.start())}: image {cfg.cosign_image}:{image_tag}, expected :{tag}" ) if cfg.cosign_identity_regexp: for pos, regexp in option_values("--certificate-identity-regexp", text): @@ -919,6 +920,7 @@ def ident(repo: str, workflow: str = "release.yml", tag: str = _TAG) -> str: f"cosign verify-blob --certificate-identity '{service_blob}' {issuer} SHA256SUMS\n" f"cosign verify --certificate-identity '{service_image}' \\\n {issuer} \\\n" f" ghcr.io/osprey-dcs/dp-service:{_TAG}\n" + f"Pull ghcr.io/osprey-dcs/dp-service:{_TAG}. Or run ghcr.io/osprey-dcs/dp-service:{_TAG}, then verify.\n" "Prose naming `--certificate-identity` is not an identity.\n" ) @@ -961,6 +963,11 @@ def wrapped(option: str, value: str) -> str: service, service_good.replace(service_image, ident("dp-service", "release-image.yml", stale)), ), + ( + "dp-service: a pre-release image tag before a period", + service, + service_good.replace(f"dp-service:{_TAG}.", f"dp-service:{_TAG}-rc1."), + ), ("dp-service: a stale image tag", service, service_good.replace(f"dp-service:{_TAG}", f"dp-service:{stale}")), ("dp-service: an identity for another workflow", service, service_good.replace("release-image.yml", "ci.yml")), (