Repository navigation
Release notes: enforce absolute, tag-pinned cross-links across all five repos #98
Description
Rescoped 2026-09-30. The original body is in this issue's edit history. It proposed a
shellgreplint added to each repo'sVerify release notes existstep. Three things changed
since then:
- Four repos now draft notes in a version-less
NEXT.md(dp-python-lib#58, adopted in
dp-grpc, dp-service, dp-desktop-app). Its links point atblob/main/…on purpose, because
the tag does not exist yet, and they are switched toblob/rel-<version>/…by hand as a step in
each repo's Cutting the release checklist. So the defect now enters at the cut, when
NEXT.mdbecomesrel-<version>.md, not while the notes are being written. The first comment's
"flagmain-pinned links" is right forrel-*.mdand wrong forNEXT.md, so the check needs
different rules for each file.- dp-python-lib already has the right tool.
.dev/tools/check-release-notes.pychecks
rel-*.mdandNEXT.mdunder different rules, runs in CI on every PR and again in
release.ymlas a backstop, and runs a self-test on known-bad input before checking anything.
It is Python and uses only the standard library, which avoids the shell traps described in the
first comment. This ticket becomes: add the link rules to that checker, then port it to the
other four repos.- Only one fix is left, and it proves the case. All five repos have the notes step, and
four of the five publishedrel-1.16.0bodies are clean. dp-python-lib's is not: line 410 of
rel-1.16.0.md, and of the published body, is a reference-style definition pinned tomain:
[readme-env]: https://github.com/osprey-dcs/dp-python-lib/blob/main/README.env. The first
comment's scan (and its "0main-pinned links remain") only looked at inline](…)links, so
this one got through. The checker must parse reference definitions as well.
Problem
Release notes are published verbatim as the GitHub release body (via body_path, or cat into a
generated body in dp-python-lib). GitHub does not resolve relative links in a release body. The
browser resolves them against /releases/tag/<tag>, so ](../../README.md#x) 404s. Links pinned to
main resolve today and break silently when the target moves. A link copied from the previous
release's notes resolves to real but stale content. None of these look wrong in a PR diff, in the
file view, or in a local preview, and review has already missed them in all five repos. That is
why this needs a check rather than a convention.
Rules
Code spans and fenced blocks are stripped before the link rules run, so documentation that
quotes a bad link does not fail its own check. The placeholder rule runs on the raw text,
because the thing it catches lives inside code blocks.
rel-X.Y.Z.md: the file being published
| # | Rule | Catches |
|---|---|---|
| R1 | No relative cross-file links. Any inline link or reference definition whose target is not http(s):, mailto:, or #… fails. |
](../../README.md), ](doc/x.md) |
| R2 | Every github.com/osprey-dcs/<repo>/(blob|tree)/<ref>/… and raw.githubusercontent.com/osprey-dcs/<repo>/<ref>/… link has <ref> equal to this file's tag. This covers all five repos, which release in lockstep. |
links left on main after the cut; stale tags copied from the previous notes |
| R3 | For links into this repo, the path exists in the checked-out tree. | a moved or renamed target |
| R4 | For links into this repo with a #anchor, the anchor matches a heading slug in the target, and the target has no duplicate headings. Same-document #anchor links are checked against this file's own headings. |
a renamed heading; a stale Contents list; a duplicate heading that silently shifts an anchor to -1 |
| R5 | No template placeholders left: rel-<version>, <version>, <previous>. |
a verify command that was never filled in (dp-service and dp-desktop-app NEXT.md carry …@refs/tags/rel-<version>) |
R3 and R4 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 cat-file, tag peeling, or network access is needed.
Notes already released get only the form rules (R1, R2, R5). They are immutable and pinned
to their own tag, so checking them against today's tree would flag an old file the first time a
README heading is renamed on main. The checker needs a way to know which files are already
released. "Every rel-*.md except the highest version" is the simplest rule that works offline;
the implementation should choose one and write it down.
NEXT.md: the draft
| # | Rule |
|---|---|
| N1 | Same as R1: no relative cross-file links. They would pass here and break once the file is published. |
| N2 | Every osprey-dcs blob/tree link uses main. A rel-* ref here is a guess at an undecided version, and R2 would require repointing it at the cut anyway. |
| N3 | R3 and R4 against the working tree. This is the check that catches the anchor risk from the original ticket. A README heading rename breaks a NEXT.md link in the same PR that renames it, and CI fails that PR, which is where the break starts. |
dp-python-lib's existing rules forbid a verification section and a Full Changelog line in NEXT.md;
those stay as they are. R5 does not apply to NEXT.md, because placeholders belong there.
Deliberately not checked
- Whether URLs resolve over the network. At check time the tag does not exist, so every
correctly pinned link 404s. The static form check (R2) is the right level. - Whether cross-repo paths and anchors exist. That target tree is not checked out. Those
links get R2 only. - Other Markdown in
doc/release-notes/(README.md, anddoc/developer/release.md). These
are read inside the repo and use relative links correctly. The checker only looks at
rel-*.mdandNEXT.md.
Also in scope: the tag in the signing identity
dp-python-lib's checker already requires every --cert-identity to end at the file's own tag,
because a stale identity makes the verify command reject every genuine artifact. The three Java
repos publish their first signed releases next, with cosign verify commands in the notes. It
makes sense to generalise the rule while the checker is being ported:
- dp-service, dp-desktop-app: each
--certificate-identityinrel-X.Y.Z.mdmust be exactly
https://github.com/osprey-dcs/<repo>/.github/workflows/<release.yml|release-image.yml>@refs/tags/<tag>.
The:rel-<tag>in dp-service's image reference must also match the tag. - dp-grpc: uses
--certificate-identity-regexpwith no tag in it, which is immune to this by
design. The check asserts the regexp is exactly the documented one, so a later edit cannot
loosen it by accident.
Where it runs
| Repo | PR-time (CI) | Release-time backstop | Notes |
|---|---|---|---|
| dp-python-lib | existing quality job | existing Verify release notes step |
Extend in place. Keep its github.event_name == 'push' guard; don't add a second rehearsal convention. |
| dp-grpc | ci.yml |
Verify release notes exist |
Port. |
| dp-service | ci.yml |
Verify release notes exist |
Port. |
| dp-desktop-app | ci.yml |
Verify release notes exist |
Port; follow its existing dry-run rule (warn on rehearsal, fail on publish). |
| data-platform | none — needs a minimal PR-triggered workflow | Verify release notes exist |
Port. Its notes are the ecosystem master and carry cross-links into all five repos (26 in rel-1.16.0.md), so R2 does the most work here. No NEXT.md yet (see open questions). |
One script, copied per repo. The core is identical in every repo; only the constants at the
top differ (repo name, which identity rules apply). A shared composite action would need a
cross-repo SHA pin and a release process of its own. That costs more than the drift risk, since
the self-test fails any copy whose rules stop matching.
Sequencing
- dp-python-lib first. Extend the existing checker, add self-test cases for every rule above
(including a stale tag, a link left onmain, amain-pinned reference definition, a relative
link inside a fenced block that must pass, a missing anchor, a duplicate heading, a leftover
rel-<version>), and run it against the realrel-1.16.0.mdandNEXT.md. R2 will fail on
rel-1.16.0.md:410as described above, so the same PR repoints that link toblob/rel-1.16.0/
and corrects the published body withgh release edit rel-1.16.0 --notes-file(the body is
generated, so regenerate it the wayrelease.ymldoes rather than uploading the bare notes). - Port to dp-grpc, dp-service, dp-desktop-app, adding the cosign identity rules. Before
merging each, run the checker against that repo's currentNEXT.md: any errors it finds are
real and should be fixed in the same PR. - data-platform: port it and add the PR-triggered workflow.
- Simplify each
NEXT.md"Cutting the release" checklist. The repointing step stays
manual, but the checklist can say "the checker will list every link you missed" instead of
relying on the reader.
All of this should land before the next release cut. That cut is the first time NEXT.md gets
renamed with links on main in it, and the first signed release in the three Java repos.
Open questions
- Should data-platform adopt
NEXT.md? It is the only repo without one. That is separate from
this ticket, but the checker already handles it, so adopting it later needs no change here. - A deliberate link to an older tag (for example "as described in the 1.15.0 notes") fails R2.
This is rare enough to handle with an explicit per-link allow marker if it ever happens; don't
build one in advance.
Definition of done
- dp-python-lib: checker extended with R1–R5 / N1–N3, and self-tests added for each
- dp-python-lib:
rel-1.16.0.md:410repointed and the publishedrel-1.16.0body corrected - dp-grpc: checker ported, cosign regexp rule, wired into
ci.ymlandrelease.yml - dp-service: checker ported, cosign identity + image-tag rules, wired into
ci.ymlandrelease.yml - dp-desktop-app: checker ported, cosign identity rule, wired into
ci.ymlandrelease.yml - data-platform: checker ported, PR-triggered workflow added, wired into
release.yml - Each repo's current
NEXT.md(where one exists) passes, or its real errors are fixed - Each "Cutting the release" checklist points at the checker
- The rule is recorded once, in data-platform's
CLAUDE.mdnext to the action-pinning
convention, rather than copied into five repos
Out of scope
- Editing already published release bodies, apart from the one dp-python-lib link in step 1. The
other fourrel-1.16.0bodies were checked on 2026-09-30 and have no relative links, no
main-pinned links, and no leftover placeholders. - dp-python-lib's
tag_namequestion from the second comment. It needs no action: the job-level
gate is the protection.
Activity
craigmcchesney commentedon Sep 16, 2026
Reviewed. The diagnosis and the approach are right, and the "why documentation alone is not enough" framing matches what actually happened — I read all 13 links in dp-grpc's notes during the #152 review and did not flag them, because they were correct in the file view I was reading.
Three corrections and three gaps, from testing the proposed lint against real files.
Corrections
1. All five repos have the step, not four. The per-repo checklist says data-platform's is a "pending change on
doc-1.16-release", butVerify release notes existis present in all fiverelease.ymlfiles as of now:Only the notes file is missing in data-platform. The lint has a home in all five today.
2. dp-python-lib's
body_pathis a red herring, in a way that strengthens the ticket's advice. It does point at generateddist/RELEASE_BODY.md, but that file is built bycat "$NOTES"followed by a verification/install appendix. The notes are concatenated verbatim, so the published body carries their links unchanged and linting the source notes file is exactly right — worth stating, since "body_path points elsewhere" reads like the lint might not apply.3. "Out of scope: editing already-published release bodies" is already done for the two that existed. dp-grpc
rel-1.16.0and dp-python-librel-1.16.0both had their stored bodies updated viagh release edit --notes-fileafter the in-repo fix; I verified 0 relative and 0main-pinned links remain in each. dp-service has no release cut yet. So the remaining scope is "future releases", not a backlog.Gaps in the proposed lint
The script works as claimed — it catches all six pre-fix dp-desktop-app links and passes the fixed file, and the code-span stripping does prevent the convention text from tripping its own check. But it misses three forms and has one false positive:
](../../README.md#x)](https://.../blob/main/doc/x.md)](https://.../blob/rel-1.15.0/README.md)in 1.16.0 notes](doc/cookbook/query.md)](../../README.md#x)inside a fenced blockmain-pinned links are the defect I found in dp-python-lib's notes (fixed in dp-python-lib#53). They resolve today and break silently later — precisely the shape this ticket argues documentation cannot catch./blob/rel-*/matches the release being cut.](doc/x.md), no leading./) 404 the same way. Cheap to add:\]\((?!https?:|#).One addition worth folding in
Since the check will have the tag and a checkout,
git cat-file -e "$GITHUB_REF_NAME:$path"verifies that every same-repo/blob/<tag>/path exists at that tag — offline, no network, no flakiness. That is strictly stronger than the form check and catches a renamed or moved target, which is the anchor-risk section's concern applied to paths rather than headings. Cross-repo links (dp-python-lib → dp-grpc) cannot be checked this way; leave those at form-only.Implementation caution
If the check is written in shell, test it against a known-good and a known-bad file deliberately. Writing throwaway verification for this by hand, I hit two silent failures that produced confident, wrong "MISS" results:
git cat-file -eneeds<tag>^{}:pathto peel an annotated tag, and${url#https://...}truncates to a single character under zsh because the#in the strip pattern opens a comment. Both looked like broken links when the links were fine. Python avoids both.craigmcchesney commentedon Sep 16, 2026
Checked dp-python-lib for the implicit
tag_namebug — it is NOT affectedContext: dp-desktop-app's
release.ymlhad a separate bug found in review of #48 — its publish stepleft
tag_nameimplicit, andaction-gh-releasedefaults it togithub.ref_name, which on aworkflow_dispatchis the selected branch. A non-dry-run dispatch frommainwould have createdor updated a release tagged
maincarryingrel-<version>artifacts and notes. Fixed there bypassing
tag_name: rel-${{ env.VERSION }}explicitly.dp-python-lib is the only other repo of the five with a
workflow_dispatchtrigger, so it was worthchecking for the same hole. It is not affected, and its
tag_nameshould be left implicit.Its publish step also omits
tag_name, but it is unreachable from a dispatch: publishing lives in aseparate job gated at the job level.
So
github.ref_namein that job is always therel-*tag, and the implicit default resolvescorrectly. The dispatch path cannot reach the publish job at all — the workflow header states this
as the design ("Manual dispatch is always a rehearsal... Publishing is gated on a
rel-tag push andnothing else"), and the job condition enforces it rather than leaving it to convention.
This is a structurally stronger guarantee than dp-desktop-app's, which gates publishing on a
step-level
if: env.DRY_RUN != 'true'and therefore does admit a dispatch-triggered publish —which is exactly why it needed the explicit
tag_nameand dp-python-lib does not.No action for dp-python-lib on this point. Adding an explicit
tag_namethere would be harmlessbut redundant, and would obscure that the real protection is the job gate.
Two further dp-python-lib findings for the checklist
1. The link lint still applies, and the notes are currently clean. Its
body_pathpoints at agenerated
dist/RELEASE_BODY.md, but the "Assemble the release body" stepcats the hand-writtendoc/release-notes/rel-<version>.mdinto it verbatim before appending install instructions. So arelative link in the source notes reaches the published body unchanged, exactly as in the other four
repos. Confirmed
doc/release-notes/rel-1.16.0.mdcurrently has no relative cross-file links.This reinforces the checklist note above: the lint belongs on the source notes file
(
steps.notes.outputs.notes), not on the generated body — by the timeRELEASE_BODY.mdexists thestep that could report a useful path has already passed.
2. Its notes step uses
if: github.event_name == 'push', not a dry-run flag. Wheredp-desktop-app warns on a rehearsal and fails on a publish, dp-python-lib skips the check entirely on
a dispatch. The lint should follow the same guard it already uses rather than introducing a second
rehearsal convention in the same file.
craigmcchesney commentedon Oct 1, 2026
PRs open for all five child tickets
The four port PRs carry a verbatim copy of the script in osprey-dcs/dp-python-lib#73; only the marked configuration block differs. So #73 should merge first, and any change made in its review has to be copied to the other four. Each repo's checker passes on its current
rel-*.mdandNEXT.md. The only real defect it found was dp-python-lib'srel-1.16.0.md:410, as this ticket predicted.Where the implementation departs from this ticket
These were decided while the work was under way. The body above still describes the original plan.
[readme-env]toblob/rel-1.16.0/, butREADME.envwas added after that tag (700d2ed, dp-python-lib#56), so that URL 404s.main.main, short SHAs and other tags still fail R2, andNEXT.mdstill requiresmain.rel-<version>and<previous>. dp-grpc, dp-service and dp-desktop-app all use a bare<version>on purpose in prose meant to stay after the cut, such asdp-service-<version>.jar.sha256, about ten times each. The case this ticket targets, an unfilled@refs/tags/rel-<version>in a verify command, is still caught..github/scripts/check-release-notes.pyin all five repos, not.dev/tools/..dev/is gitignored in three of them. The script now finds the repo root by walking up to.gitinstead of using a fixed depth.rel-*.mdexcept the highest version. Those files get R1, R2 and R5 only.push-only guard, as asked above, so a dispatch skips the check there.release.ymlfiles now use an input-lessIS_RELEASE, notdry_run/DRY_RUN; dp-desktop-app changed in Sign release artifacts with keyless Sigstore (cosign) dp-desktop-app#24.DRY_RUNremains only in data-platform'srelease.yml, dp-service'srelease-image.ymland dp-grpc'sgenerate-python-stubs.yml.### Methodsheadings inrel-1.16.0.mdwould fail.releases/tag/…andcompare/…links are not checked by R2, because linking an earlier release is legitimate. The script's docstring says so.Definition of done: what's left after merge
gh release edit rel-1.16.0 -R osprey-dcs/dp-python-lib --notes-file doc/release-notes/rel-1.16.0.md. The published body is now the notes file unchanged, so no regeneration is needed. The only diff is the one link.release.ymlin each repo, to confirm the backstop runs.CLAUDE.mdsection and thedoc-102-distribution-modelbranch (keep both sections).data-platform's
CLAUDE.mdsection, in #109, records the rule once for all five repos.craigmcchesney commentedon Oct 2, 2026
All five child tickets are merged and closed, and CI is green on
mainin every repo:.github/scripts/check-release-notes.pyis the same file in all five repos except its marked repo-specific configuration block. It runs in each repo'sci.ymland in the "Verify release notes exist" step ofrelease.yml.Published release fixed. In the live
rel-1.16.0release body for dp-python-lib, the[readme-env]link pointed atblob/main/README.env. It now points at the commit-pinned URLblob/700d2edf…/README.env, which matchesdoc/release-notes/rel-1.16.0.md. That was the only change to the body.Rehearsals. I dispatched
release.ymlagainstmainin all five repos, and all five succeeded:OK, 2 filesOK, 3 filesOK, 1 fileversion=1.16.0,dry_run=true): checkerOK, 1 fileClosing.