Skip to content

Release notes: enforce absolute, tag-pinned cross-links across all five repos #98

Closed
5 of 5 issues completed

Description

@craigmcchesney

Rescoped 2026-09-30. The original body is in this issue's edit history. It proposed a
shell grep lint added to each repo's Verify release notes exist step. Three things changed
since then:

  1. 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 at blob/main/… on purpose, because
    the tag does not exist yet, and they are switched to blob/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.md becomes rel-<version>.md, not while the notes are being written. The first comment's
    "flag main-pinned links" is right for rel-*.md and wrong for NEXT.md, so the check needs
    different rules for each file.
  2. dp-python-lib already has the right tool. .dev/tools/check-release-notes.py checks
    rel-*.md and NEXT.md under different rules, runs in CI on every PR and again in
    release.yml as 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.
  3. Only one fix is left, and it proves the case. All five repos have the notes step, and
    four of the five published rel-1.16.0 bodies 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 to main:
    [readme-env]: https://github.com/osprey-dcs/dp-python-lib/blob/main/README.env. The first
    comment's scan (and its "0 main-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, and doc/developer/release.md). These
    are read inside the repo and use relative links correctly. The checker only looks at
    rel-*.md and NEXT.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-identity in rel-X.Y.Z.md must 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-regexp with 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

  1. dp-python-lib first. Extend the existing checker, add self-test cases for every rule above
    (including a stale tag, a link left on main, a main-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 real rel-1.16.0.md and NEXT.md. R2 will fail on
    rel-1.16.0.md:410 as described above, so the same PR repoints that link to blob/rel-1.16.0/
    and corrects the published body with gh release edit rel-1.16.0 --notes-file (the body is
    generated, so regenerate it the way release.yml does rather than uploading the bare notes).
  2. Port to dp-grpc, dp-service, dp-desktop-app, adding the cosign identity rules. Before
    merging each, run the checker against that repo's current NEXT.md: any errors it finds are
    real and should be fixed in the same PR.
  3. data-platform: port it and add the PR-triggered workflow.
  4. 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:410 repointed and the published rel-1.16.0 body corrected
  • dp-grpc: checker ported, cosign regexp rule, wired into ci.yml and release.yml
  • dp-service: checker ported, cosign identity + image-tag rules, wired into ci.yml and release.yml
  • dp-desktop-app: checker ported, cosign identity rule, wired into ci.yml and release.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.md next 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 four rel-1.16.0 bodies were checked on 2026-09-30 and have no relative links, no
    main-pinned links, and no leftover placeholders.
  • dp-python-lib's tag_name question from the second comment. It needs no action: the job-level
    gate is the protection.

Activity

  1. craigmcchesney commented on Sep 16, 2026

    @craigmcchesney
    CollaboratorAuthor

    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", but Verify release notes exist is present in all five release.yml files as of now:

    dp-grpc          step=1     dp-service     step=1
    dp-desktop-app   step=1     dp-python-lib  step=1
    data-platform    step=1
    

    Only the notes file is missing in data-platform. The lint has a home in all five today.

    2. dp-python-lib's body_path is a red herring, in a way that strengthens the ticket's advice. It does point at generated dist/RELEASE_BODY.md, but that file is built by cat "$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.0 and dp-python-lib rel-1.16.0 both had their stored bodies updated via gh release edit --notes-file after the in-repo fix; I verified 0 relative and 0 main-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:

    Input Expected Actual
    ](../../README.md#x) flag ✅ flagged
    ](https://.../blob/main/doc/x.md) flag ❌ missed
    ](https://.../blob/rel-1.15.0/README.md) in 1.16.0 notes flag ❌ missed
    ](doc/cookbook/query.md) flag ❌ missed
    ](../../README.md#x) inside a fenced block ignore ❌ false positive
    • main-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.
    • Stale tags are the likeliest future failure, because the next release note starts life as a copy of the last one and every URL carries the old tag. It resolves to real content, so nothing looks wrong. Worth asserting that each /blob/rel-*/ matches the release being cut.
    • Bare relative paths (](doc/x.md), no leading ./) 404 the same way. Cheap to add: \]\((?!https?:|#).
    • Fenced code blocks need stripping alongside code spans, or the ticket's own example text will fail its own lint once it is pasted into a repo's conventions doc.

    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 -e needs <tag>^{}:path to 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.

  2. craigmcchesney commented on Sep 16, 2026

    @craigmcchesney
    CollaboratorAuthor

    Checked dp-python-lib for the implicit tag_name bug — it is NOT affected

    Context: dp-desktop-app's release.yml had a separate bug found in review of #48 — its publish step
    left tag_name implicit, and action-gh-release defaults it to github.ref_name, which on a
    workflow_dispatch is the selected branch. A non-dry-run dispatch from main would have created
    or updated a release tagged main carrying rel-<version> artifacts and notes. Fixed there by
    passing tag_name: rel-${{ env.VERSION }} explicitly.

    dp-python-lib is the only other repo of the five with a workflow_dispatch trigger, so it was worth
    checking for the same hole. It is not affected, and its tag_name should be left implicit.

    Its publish step also omits tag_name, but it is unreachable from a dispatch: publishing lives in a
    separate job gated at the job level.

    publish-github-release:
      if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rel-')

    So github.ref_name in that job is always the rel-* tag, and the implicit default resolves
    correctly. 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 and
    nothing 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_name and dp-python-lib does not.

    No action for dp-python-lib on this point. Adding an explicit tag_name there would be harmless
    but 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_path points at a
    generated dist/RELEASE_BODY.md, but the "Assemble the release body" step cats the hand-written
    doc/release-notes/rel-<version>.md into it verbatim before appending install instructions. So a
    relative 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.md currently 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 time RELEASE_BODY.md exists the
    step that could report a useful path has already passed.

    2. Its notes step uses if: github.event_name == 'push', not a dry-run flag. Where
    dp-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.

  3. craigmcchesney commented on Oct 1, 2026

    @craigmcchesney
    CollaboratorAuthor

    PRs open for all five child tickets

    Repo Ticket PR
    dp-python-lib #70 osprey-dcs/dp-python-lib#73 (merge first)
    dp-grpc #168 osprey-dcs/dp-grpc#169
    dp-service #305 osprey-dcs/dp-service#306
    dp-desktop-app #51 osprey-dcs/dp-desktop-app#52
    data-platform #107 #109

    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-*.md and NEXT.md. The only real defect it found was dp-python-lib's rel-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.

    1. R2 also accepts a full 40-character commit SHA. The ticket says to repoint dp-python-lib's [readme-env] to blob/rel-1.16.0/, but README.env was added after that tag (700d2ed, dp-python-lib#56), so that URL 404s.
      • The link is pinned to 700d2ed instead.
      • A full SHA can't drift, unlike main. main, short SHAs and other tags still fail R2, and NEXT.md still requires main.
      • SHA-pinned links get the form check only, with no path or anchor check.
    2. R5 covers only 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 as dp-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.
    3. The script is at .github/scripts/check-release-notes.py in all five repos, not .dev/tools/. .dev/ is gitignored in three of them. The script now finds the repo root by walking up to .git instead of using a fixed depth.
    4. "Already released" means every rel-*.md except the highest version. Those files get R1, R2 and R5 only.
    5. A rehearsal warns and a release fails in all four ported repos. For dp-grpc and dp-service, that is a deliberate exception, in the notes step only, to their release.yml's "a rehearsal should fail on a real problem" rule. dp-python-lib keeps its push-only guard, as asked above, so a dispatch skips the check there.
    6. The "Where it runs" table and the dp-desktop-app sequencing note are out of date. All three Java release.yml files now use an input-less IS_RELEASE, not dry_run/DRY_RUN; dp-desktop-app changed in Sign release artifacts with keyless Sigstore (cosign) dp-desktop-app#24. DRY_RUN remains only in data-platform's release.yml, dp-service's release-image.yml and dp-grpc's generate-python-stubs.yml.
    7. R4's duplicate-heading rule fires only when a link's anchor lands on a duplicated heading, not on any duplicate in the target. Otherwise dp-python-lib's two unlinked ### Methods headings in rel-1.16.0.md would fail.
    8. releases/tag/… and compare/… 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

    • Run 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.
    • Run a dry-run dispatch of release.yml in each repo, to confirm the backstop runs.
    • Resolve a probable merge conflict between ci: release-notes checker and a PR-triggered workflow (#107) #109's CLAUDE.md section and the doc-102-distribution-model branch (keep both sections).

    data-platform's CLAUDE.md section, in #109, records the rule once for all five repos.

  4. craigmcchesney commented on Oct 2, 2026

    @craigmcchesney
    CollaboratorAuthor

    All five child tickets are merged and closed, and CI is green on main in every repo:

    Repo Ticket PR Merge
    dp-python-lib #70 #73 f04d830
    dp-grpc #168 #169 9d09410
    dp-service #305 #306 2dd11df
    dp-desktop-app #51 #52 6b31121
    data-platform #107 #109 70ae2d5

    .github/scripts/check-release-notes.py is the same file in all five repos except its marked repo-specific configuration block. It runs in each repo's ci.yml and in the "Verify release notes exist" step of release.yml.

    Published release fixed. In the live rel-1.16.0 release body for dp-python-lib, the [readme-env] link pointed at blob/main/README.env. It now points at the commit-pinned URL blob/700d2edf…/README.env, which matches doc/release-notes/rel-1.16.0.md. That was the only change to the body.

    Rehearsals. I dispatched release.yml against main in all five repos, and all five succeeded:

    • osprey-dcs/dp-grpc run 37045985954: checker OK, 2 files
    • osprey-dcs/dp-service run 37045991293: checker OK, 3 files
    • osprey-dcs/dp-desktop-app run 37045995786: checker OK, 1 file
    • osprey-dcs/data-platform run 37046001252 (version=1.16.0, dry_run=true): checker OK, 1 file
    • osprey-dcs/dp-python-lib run 37045978239: built and signed. By design, a dispatch here skips the notes check. CI and the tag-push release still run it.

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions