diff --git a/.dev/tools/check-release-notes.py b/.dev/tools/check-release-notes.py deleted file mode 100755 index bf59223..0000000 --- a/.dev/tools/check-release-notes.py +++ /dev/null @@ -1,283 +0,0 @@ -#!/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. - -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. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -NOTES_DIR = REPO_ROOT / "doc" / "release-notes" -NEXT_NAME = "NEXT.md" - -REPOSITORY = "osprey-dcs/dp-python-lib" -OIDC_ISSUER = "https://token.actions.githubusercontent.com" - -STEM_RE = re.compile(r"^rel-(\d+)\.(\d+)\.(\d+)$") -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]+)\"?") -# 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) -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. -REQUIRED_OPERANDS = [ - ("the wheel", lambda arg: arg.endswith(".whl")), - ("the sdist", lambda arg: arg.endswith(".tar.gz")), - ("SHA256SUMS", lambda arg: arg == "SHA256SUMS"), -] - - -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 verify_operands(command: str) -> list[str]: - """The file operands of one `sigstore verify identity` command: every token that is not an option or its value.""" - tokens = command.replace("\\\n", " ").split()[3:] - operands: list[str] = [] - skip_value = False - for token in tokens: - if skip_value: - skip_value = False - elif token.startswith("--"): - skip_value = "=" not in token - else: - operands.append(token) - 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.""" - problems: list[str] = [] - - if not HEADING_RE.search(text): - problems.append(f"{name}: missing a '## Verifying these artifacts' section") - - commands = VERIFY_RE.findall(text) - if not commands: - problems.append(f"{name}: missing a 'sigstore verify identity' command") - for command in commands: - operands = verify_operands(command) - for description, matches in REQUIRED_OPERANDS: - 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) - if not identities: - problems.append(f"{name}: missing --cert-identity") - want = expected_identity(tag) - 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) - 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}") - - 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) - 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}" - ) - continue - prev, this = compare.groups() - if this != tag: - problems.append(f"{name}: Full Changelog link ends at {this}, expected {tag}") - elif version_of(prev) >= version_of(tag): - problems.append(f"{name}: Full Changelog link starts at {prev}, which is not earlier than {tag}") - - 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.""" - problems: list[str] = [] - forbidden = [ - (HEADING_RE, "a '## Verifying these artifacts' section"), - (VERIFY_COMMAND_RE, "a 'sigstore verify identity' command"), - (IDENTITY_RE, "a --cert-identity"), - (CHANGELOG_RE, "a '**Full Changelog**' line"), - ] - for pattern, description in forbidden: - if pattern.search(text): - problems.append( - f"{name}: contains {description}, which names the release's tag; NEXT.md names no version, " - "so add it when the file is renamed at the cut" - ) - return problems - - -def check_file(path: Path) -> list[str]: - """Returns one message per problem found in `path`; empty when it passes.""" - if path.name == NEXT_NAME: - return check_next_text(str(path), path.read_text(encoding="utf-8")) - 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")) - - -def _sample( - identity_tag: str = "rel-2.1.0", - files: str = "dp_python_lib-*.whl dp_python_lib-*.tar.gz SHA256SUMS", - changelog: str | None = "rel-2.0.0...rel-2.1.0", -) -> str: - notes = f"""# dp-python-lib rel-2.1.0 - -## Verifying these artifacts - -```bash -sigstore verify identity \\ - --cert-identity "{expected_identity(identity_tag)}" \\ - --cert-oidc-issuer "{OIDC_ISSUER}" \\ - {files} -``` -""" - if changelog is not None: - notes += f"\n**Full Changelog**: https://github.com/{REPOSITORY}/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") - - # The draft's own checklist names each forbidden part in prose; that must not trip the rule. - next_draft = """# Release Notes — next release (unreleased) - -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-`. -""" - good_next = check_next_text("", next_draft) - 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", - }.items(): - if not check_next_text(f"<{description}>", notes): - failures.append(f"{description} was accepted") - return failures - - -def main(argv: list[str]) -> int: - canary_failures = self_test() - if canary_failures: - print("FAIL: checker self-test failed; its rules no longer catch what they are meant to\n") - for failure in canary_failures: - print(f" {failure}") - return 1 - - if argv: - paths = [Path(arg) for arg in argv] - else: - paths = sorted(NOTES_DIR.glob("rel-*.md")) - if (NOTES_DIR / NEXT_NAME).is_file(): - paths.append(NOTES_DIR / NEXT_NAME) - if not paths: - print(f"FAIL: no release notes found under {NOTES_DIR}") - return 1 - - problems: list[str] = [] - for path in paths: - if not path.is_file(): - problems.append(f"{path}: not found") - continue - problems.extend(check_file(path)) - - if problems: - print(f"FAIL: {len(problems)} problem(s) in {len(paths)} release notes file(s)\n") - for problem in problems: - print(f" {problem}") - return 1 - - print(f"OK: {len(paths)} release notes file(s)") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/check-release-notes.py b/.github/scripts/check-release-notes.py new file mode 100755 index 0000000..2815017 --- /dev/null +++ b/.github/scripts/check-release-notes.py @@ -0,0 +1,1079 @@ +#!/usr/bin/env python3 +"""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, 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). + 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. + 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. 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. + + 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. + +Deliberately not checked: whether URLs resolve over the network (before the tag is pushed, every correctly pinned +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 +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 [FILE ...] + +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. +""" + +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-" + +# 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. +# +# 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. +# ================================================================================================================== + + +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" +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+)$") + + +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, 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[ \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(strip_code(text))) # an `` quoted in code is not an anchor + 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 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() + else: + raw = RAW_RE.match(url) + if not raw: + 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, + 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 +# ------------------------------------------------------------------------------------------------------------------ + +# 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]: + """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: + # 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: + 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. +SIGSTORE_VERIFY_RE = re.compile(r"\bsigstore\s+verify\s+identity\b(?:[^\n]*\\\n)*[^\n]*") +# 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) + +# What each `sigstore verify identity` must name, as (description, predicate over its file operands). Every +# 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")), + ("SHA256SUMS", lambda arg: arg == "SHA256SUMS"), +] + + +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]: + """The file operands of one `sigstore verify identity` command: every token that is not an option or its value.""" + tokens = command.replace("\\\n", " ").split()[3:] + operands: list[str] = [] + skip_value = False + for token in tokens: + if skip_value: + skip_value = False + elif token.startswith("--"): + skip_value = "=" not in token + else: + operands.append(token) + return operands + + +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 VERIFY_HEADING_RE.search(text): + problems.append(f"{name}: missing a '## Verifying these artifacts' section") + + commands = SIGSTORE_VERIFY_RE.findall(text) + if not commands: + problems.append(f"{name}: missing a 'sigstore verify identity' command") + for command in commands: + operands = verify_operands(command) + for description, matches in REQUIRED_OPERANDS: + if not any(matches(arg) for arg in operands): + problems.append(f"{name}: 'sigstore verify identity' does not verify {description}") + + identities = SIGSTORE_IDENTITY_RE.findall(text) + if not identities: + problems.append(f"{name}: missing --cert-identity") + 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 = 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) + if compare is None: + problems.append( + f"{name}: Full Changelog link is\n {url}\n expected" + f"\n https://github.com/{cfg.repository}/compare/rel-...{tag}" + ) + continue + prev, this = compare.groups() + if this != tag: + problems.append(f"{name}: Full Changelog link ends at {this}, expected {tag}") + elif version_of(prev) >= version_of(tag): + problems.append(f"{name}: Full Changelog link starts at {prev}, which is not earlier than {tag}") + + return problems + + +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 = [ + (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: + if pattern.search(text): + problems.append( + f"{name}: contains {description}, which names the release's tag; NEXT.md names no version, " + "so add it when the file is renamed at the cut" + ) + return problems + + +# ------------------------------------------------------------------------------------------------------------------ +# 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 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)}{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)] + + +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): + 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}:{image_tag}, 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")), "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)"], "-" + 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 _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: + notes = f"""# dp-python-lib rel-2.1.0 + +## Verifying these artifacts + +```bash +sigstore verify identity \\ + --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**: {_PY}/compare/{changelog}\n" + return notes + + +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```\n\nSee ``.\n', + encoding="utf-8", + ) + (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. +_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) +- [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) +- 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` +- 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). +[stale]: {_PY}/blob/rel-1.0.0/README.md +``` + +[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. +_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) +- not in lockstep, so unchecked: [dp-support](https://github.com/osprey-dcs/dp-support/blob/rel-1.0.0/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**: {_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 +""" + + +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 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", + "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.", + "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`", + "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)", + "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 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)", + "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"): + 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", + "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)", + "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_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) + 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") + 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"), + "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"), + "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") + + # 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": _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_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" + 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" + ) + + 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" + 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" + wrapped("--certificate-identity-regexp", DP_GRPC_IDENTITY_REGEXP) + ) + 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 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")), + ( + "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-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/'")), + ]: + 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) + (root / "tree").mkdir() + _make_tree(root / "tree") + failures += _self_test_links(root / "tree") + failures += _self_test_repo_root(root) + return failures + + +def main(argv: list[str]) -> int: + canary_failures = self_test() + if canary_failures: + print("FAIL: checker self-test failed; its rules no longer catch what they are meant to\n") + for failure in canary_failures: + print(f" {failure}") + return 1 + + if argv: + paths = [Path(arg) for arg in argv] + else: + paths = sorted(NOTES_DIR.glob("rel-*.md")) + if (NOTES_DIR / NEXT_NAME).is_file(): + paths.append(NOTES_DIR / NEXT_NAME) + if not paths: + print(f"FAIL: no release notes found under {NOTES_DIR}") + return 1 + + problems: list[str] = [] + for path in paths: + if not path.is_file(): + problems.append(f"{path}: not found") + continue + 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"\nFAIL: {len(problems)} problem(s) in {len(paths)} release notes file(s)\n") + for problem in problems: + print(f" {problem}") + return 1 + + print(f"OK: {len(paths)} release notes file(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) 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 e20cd87..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 @@ -158,6 +158,27 @@ 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; 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 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 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..f71b86a 100644 --- a/doc/release-notes/NEXT.md +++ b/doc/release-notes/NEXT.md @@ -13,12 +13,17 @@ 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 `--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 .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/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 diff --git a/plan/tickets/70/plan.md b/plan/tickets/70/plan.md new file mode 100644 index 0000000..3f75a3a --- /dev/null +++ b/plan/tickets/70/plan.md @@ -0,0 +1,83 @@ +# 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 + +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. + +## 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. + +**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 + +- `.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). +- `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.