Skip to content

fix(release): cut dependent releases for Java framework changes - #595

Open
balajinvda wants to merge 3 commits into
mainfrom
fix/release-java-framework-fanout
Open

fix(release): cut dependent releases for Java framework changes#595
balajinvda wants to merge 3 commits into
mainfrom
fix/release-java-framework-fanout

Conversation

@balajinvda

@balajinvda balajinvda commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Why

A change to the shared Java framework src/libraries/java/nv-boot-parent
rebuilds every dependent Java service in CI but releases none of them.

The build-side edge already exists. .github/workflows/bazel.yml discovers
Java rows from per-component bazel-java-ci.json descriptors, sets
java_framework_changed=true at line 249 when a changed path sits under a
component_kind: java-framework component, and at line 326 schedules every
component_kind: java-service row. So cloud-tasks, notary, and api-keys
are built and tested against the new framework.

The release side has no matching edge. tools/ci/github-release auto runs
npx semantic-release per registered subproject with semantic-release-monorepo
(write_semantic_release_files, line 367), which scopes commits to the service
directory. A commit touching only src/libraries/java/nv-boot-parent matches no
registered service path, so every dependent reports "no relevant changes" and
cuts nothing. nv-boot-parent is not itself one of the 42 registered
subprojects in tools/ci/github-release-subprojects.json, so nothing is
released for the framework either.

Net effect: a framework change can alter the behaviour of three services, pass
CI, and ship no new version of any of them. The rebuilt artifacts exist only
inside the CI job.

This is not hypothetical on current main. HEAD is
feat(nv-boot): discover NGC auth via authenticate challenge (#557), a
framework-only commit that landed after the last release tag of all three
dependents.

What changed

tools/ci/github-release gains a dependency fan-out for Java services.

  • java_ci_components() reads the same src/**/bazel-java-ci.json descriptors
    bazel.yml reads. The framework-to-service edge stays declared in exactly one
    place; no second list is introduced and no field is added to the generated
    release metadata (its generator is not mirrored here).
  • publish_framework_dependency_release() cuts a patch release through the
    existing publish_tag_for_version() helper, with a reason that says the
    release is dependency-triggered and lists the framework commits.
  • resolve_release_outcome() / finish_semantic_release() classify a
    semantic-release run. semantic-release wins whenever it computed a version, so
    a push touching both a framework and a service is never double-tagged.
  • auto_release() calls the fan-out on both paths that end in "nothing to
    release": the only_generated_changes() early return, and the
    semantic-release "no relevant changes" result.
  • The publish-mode npx semantic-release invocation now streams its output
    instead of discarding it, because the fan-out decision depends on whether
    semantic-release released anything. A non-zero exit still raises, as before.

Guards, all of which have tests:

  • Only components whose path matches a java-service descriptor are eligible.
  • The service must already have a stable X.Y.Z release tag to bump from.
  • At least one release-worthy commit must have touched a framework path since
    that tag, bounded by latest_service_tag(), so a rerun over the same
    framework commit is a no-op. Release-worthiness is derived from the existing
    RELEASE_RULES constant, so a framework docs: or chore: commit fans out
    nothing, exactly as it would release nothing inside a service directory.
  • Dry-run and auto_tagging_enabled=false are honored by reusing
    publish_tag_for_version(), which prints the tag and notes and creates
    nothing.

Design decision: patch, not minor, even for a framework feat:

The synthesized bump is always a patch, including when the framework commit is a
feat:. Reasons, in order:

  1. A framework feature adds no capability to a service that has not adopted it.
    The service's public surface is unchanged; only its build inputs moved. Minor
    would advertise functionality the service does not have.
  2. The dependent's release notes contain no service commits, so a minor has
    nothing in that service's changelog to substantiate it.
  3. Dependency-triggered releases stay in the patch lane, so a framework change
    can never consume a service's minor line or collide with a planned minor.
    Minor and major bumps remain owned by commits in the service's own directory.
  4. A service that genuinely adopts a new framework API does so in a commit under
    its own directory. semantic-release computes the correct bump from that
    commit, and this fan-out does not run at all in that case.

The same argument applies more strongly to a framework BREAKING CHANGE: a
silent major fan-out would be the most surprising outcome of all, so it is not
implemented. A breaking framework change should be adopted deliberately in each
service.

This behaviour is documented in docs/dev/github-release-process.md under
"Java framework dependency releases", so it is not a surprise at the console.

Customer Release Notes

Not customer visible. Release automation only.

Plan Summary

Not applicable. No infrastructure, chart, or deploy changes.

Usage

No new commands. The existing entrypoint covers it:

./tools/ci/github-release auto

Behaviour is unchanged for every non-Java subproject and for Java subprojects
whose own directory changed.

Testing

tools/ci/test-github-release.py (run by .github/workflows/build-test.yml)
goes from 7 tests to 20, all passing:

Ran 20 tests in 0.49s
OK

New coverage:

  • a framework change with no service change produces a patch bump for every
    dependent service
  • framework docs: / chore: / refactor: / non-conventional commits fan out
    nothing, and the release notes quote only the release-worthy commits
  • a framework change plus a service change does not double-tag; the
    semantic-release version wins and no extra tag is created
  • no framework change since the last service tag produces nothing
  • dry-run creates no tag and no GitHub Release
  • publish mode creates the tag, pushes it to a real bare remote, and passes the
    dependency-triggered notes to create_release
  • a service with no prior release tag is skipped rather than seeded
  • non-Java subprojects and the framework component itself are never fanned out
  • java-service descriptors and registered subprojects are checked for drift
    against the real repo, so a new Java service that is not registered fails the
    suite instead of silently never releasing
  • releases_a_version() matches the configured RELEASE_RULES for every
    declared type
  • a semantic-release run that exits non-zero is classified as undecided and
    produces neither a preview tag nor a dependency fan-out

Also validated against real main state by running the new helpers over the
actual checkout.

Read this table as a demonstration of the mechanism against the anchors that
exist on main today, not as the versions these services should be released at.
Two of the three anchors are on the wrong version line, which is the merge-order
dependency described under Notes. The "would cut" column is what the fan-out
computes from the current, incorrect anchors.

service latest tag on main today release-worthy framework commits since would cut from that anchor correct line
cloud-tasks src/control-plane-services/cloud-tasks/v1.62.2 1 1.62.3 1.6.x
notary src/control-plane-services/notary/v1.12.0 1 1.12.1 1.8.x
api-keys src/control-plane-services/api-keys/v1.6.1 1 1.6.2 1.6.x

Not run: an end-to-end github-release auto against main, which needs npm and
network access to install semantic-release. The semantic-release interaction is
covered by feeding recorded semantic-release output through
finish_semantic_release().

Notes

Where the base version comes from

There is no hardcoded base or current version anywhere in this PR, and none in
this repository. The base version is read from Git tags in this repository:

  1. tools/ci/github-release-subprojects.json gives each service its path.
  2. default_tag_format() (line 121) turns that path into <path>/v${version},
    and tag_prefix() (line 132) strips the placeholder to get the tag prefix.
  3. latest_service_tag() (line 282) runs git tag -l "<prefix>*" and returns
    the semver-max match, ordered by semverish_sort_key() (line 117).
  4. publish_framework_dependency_release() (line 598) takes that tag, extracts
    the version with version_from_tag() (line 159), and increments the patch
    with next_patch_version() (line 531).

So the base version is whatever the highest existing tag on main says. The
service pom.xml files are all 0.0.1-SNAPSHOT and are not a version source,
and none of these three services has a VERSION file. Nothing in this code
path reads a version from outside this repository.

Merge-order dependency: fix the version anchors first

Because the base version is the semver-max existing tag, a wrong anchor
propagates. notary and cloud-tasks are on the wrong version line today:

service latest tag on main correct current version
cloud-tasks src/control-plane-services/cloud-tasks/v1.62.2 1.6.2
notary src/control-plane-services/notary/v1.12.0 1.8.1

Adding a correct anchor is not sufficient on its own. semverish_sort_key()
compares numerically per component, so with both v1.12.0 and a new v1.8.1
present, the max is still v1.12.0, and with both v1.62.2 and a new v1.6.2
present, the max is still v1.62.2. Verified against the function.

Required order before auto-tagging is enabled:

  1. Delete the wrong anchors src/control-plane-services/notary/v1.12.0 and
    src/control-plane-services/cloud-tasks/v1.62.2.
  2. Re-anchor notary at 1.8.1 and cloud-tasks at 1.6.2.
  3. Only then set NVCF_GITHUB_AUTO_TAGGING_ENABLED=true.

Merging this PR before that is safe: NVCF_GITHUB_AUTO_TAGGING_ENABLED defaults
to false, so release-tags.yml runs dry-run only and creates no tag and no
Release.

api-keys needs confirmation, not correction: main carries both
src/control-plane-services/api-keys/v1.6.0 and v1.6.1, so latest_service_tag()
returns v1.6.1 and the fan-out would cut 1.6.2. If v1.6.1 is a real release
that is already correct. If it is a stray anchor and 1.6.0 is current, it should
be deleted along with the other two.

Scope

  • nv-boot-parent itself is still not a registered subproject and still cuts no
    tag of its own. This PR does not change that; it makes the framework change
    reach released artifacts through its dependents. Registering the framework as
    its own release line is a separate decision.
  • The descriptors declare the edge as "any framework affects every service",
    which is exactly what bazel.yml implements today. If a per-service framework
    allowlist is ever needed, it belongs in the descriptors so both consumers pick
    it up.

References

Fixes #594

Related Merge Requests/Pull Requests

None.

Review follow-ups

  • Review flagged that the first pass fanned out on any commit touching a
    framework path, including a framework-only docs: or chore:. Fixed in
    b9f41b8: the commit list is now filtered through RELEASE_RULES.
  • Review flagged that resolve_release_outcome() ignored the exit code when the
    output contained a version, so a dry-run that printed a version and then died
    previewed a tag the publish run may never create. Fixed in 6ba76cd: any
    non-zero exit is unknown and lands in the dry-run preview summary. Publish
    mode is unaffected; it already raises on a non-zero exit.

Dependencies

None. No new third-party packages; standard library only.

Issues

Fixes #594

Checklist

  • I am familiar with the Contributing Guidelines.
  • I have signed off my commits for Developer Certificate of Origin (DCO) compliance.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

A change under src/libraries/java/nv-boot-parent rebuilds cloud-tasks,
notary, and api-keys in CI but releases none of them. semantic-release
runs per registered subproject with semantic-release-monorepo, which
scopes commits to the service directory, so a framework-only commit
matches no service path and every dependent reports "no relevant
changes". The rebuilt artifacts never leave the CI job.

github-release now reads the same per-component bazel-java-ci.json
descriptors that .github/workflows/bazel.yml reads for its matrix, so
the framework-to-service edge stays declared in exactly one place. When
semantic-release computes no version for a java-service subproject and
at least one commit touched a java-framework path since that service's
last release tag, the script cuts a patch release for it.

The bump is always a patch, including for a framework feat:. A
framework feature adds no capability to a service that has not adopted
it, and the service changelog has nothing to substantiate a minor. A
service that does adopt a new framework API does so in a commit under
its own directory, which semantic-release turns into the correct bump,
and the fan-out does not run at all in that case.

Guards: semantic-release wins whenever it computed a version, so a push
touching both a framework and a service is never double-tagged; the
service must already have a stable release tag to bump from; the
framework must have changed since that tag; and dry-run prints the tag
and notes and creates nothing. Release notes state that the release is
dependency-triggered and list the framework commits so a reader of a
GitHub Release with no changes in the service directory can see why the
version moved.

The publish-mode semantic-release invocation now streams its output
instead of discarding it, because the fan-out decision depends on
whether semantic-release released anything.

Signed-off-by: Balaji Ganesan <bganesan@nvidia.com>
Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@balajinvda
balajinvda requested review from a team as code owners July 31, 2026 03:56
@balajinvda
balajinvda requested review from Max-NV and vrv3814 July 31, 2026 03:56
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The release tool discovers Java framework dependencies and creates dependent-service patch releases when semantic-release finds no service-local changes. Tests cover discovery, fan-out, tagging, publishing, dry runs, and outcome routing.

Changes

Java framework dependency releases

Layer / File(s) Summary
Java component discovery
tools/ci/github-release, tools/ci/test-github-release.py
The tool loads bazel-java-ci.json descriptors, validates component kinds, identifies frameworks and services, and registers Java services for release processing.
Framework dependency fan-out
tools/ci/github-release, tools/ci/test-github-release.py, docs/dev/github-release-process.md
Eligible services receive patch releases when framework commits exist after their latest stable tag and no service-local release occurred. Tests cover filtering, version calculation, tags, dry runs, and publishing.
Semantic-release outcome routing
tools/ci/github-release, tools/ci/test-github-release.py
Semantic-release outcomes are classified and finalized centrally. No-release outcomes can trigger dependency releases, while unknown outcomes receive separate dry-run and publish handling. Tests cover outcome precedence and fallback paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: max-nv

Sequence Diagram(s)

sequenceDiagram
  participant auto_release
  participant semantic_release
  participant finish_semantic_release
  participant publish_framework_dependency_release
  participant Git
  auto_release->>semantic_release: evaluate service-local changes
  semantic_release-->>finish_semantic_release: return release outcome
  finish_semantic_release->>publish_framework_dependency_release: handle no-release outcome
  publish_framework_dependency_release->>Git: inspect framework commits and service tags
  publish_framework_dependency_release->>Git: create and push service patch tag
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes use Java descriptors to trigger dependent service releases for qualifying framework changes, as required by issue #594.
Out of Scope Changes check ✅ Passed The documentation and tests directly support the Java framework dependency-release implementation and introduce no unrelated changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately describes the release automation fix for dependent Java framework changes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/release-java-framework-fanout

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tools/ci/test-github-release.py (1)

144-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a framework commit that creates no release.

Every fan-out test uses fix(nv-boot): bump shared framework. No test covers a framework-only docs: or chore: commit. That is the case flagged at tools/ci/github-release lines 534-540, where such a commit currently triggers a patch release for every dependent Java service. Add a test that commits a docs: change under JAVA_FRAMEWORK_PATH and asserts that publish_framework_dependency_release returns False. The coding guidelines require tests for changed tool behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/ci/test-github-release.py` around lines 144 - 159, Add a test alongside
test_framework_change_fans_out_a_patch_release_to_every_dependent_service that
commits a docs-only change under JAVA_FRAMEWORK_PATH, invokes
publish_framework_dependency_release, and asserts it returns False. Reuse the
existing temporary Java repository setup and framework commit helpers where
applicable, without asserting or creating dependent service releases.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/ci/github-release`:
- Around line 622-628: Update resolve_release_outcome so the "released" result
is returned only when exit_code is zero and parse_next_version(output) succeeds.
Preserve the existing no-release and unknown classification behavior, ensuring
non-zero exits with a parsed version are treated as unknown.
- Around line 534-540: Update framework_commits_since to filter its git log
results to release-worthy conventional commit types only, excluding docs, chore,
ci, test, refactor, and other non-releasing changes while preserving the
existing commit format and empty-list skip. Also update the framework fan-out
documentation section to state that only release-worthy framework commits
trigger dependent Java service releases.

---

Nitpick comments:
In `@tools/ci/test-github-release.py`:
- Around line 144-159: Add a test alongside
test_framework_change_fans_out_a_patch_release_to_every_dependent_service that
commits a docs-only change under JAVA_FRAMEWORK_PATH, invokes
publish_framework_dependency_release, and asserts it returns False. Reuse the
existing temporary Java repository setup and framework commit helpers where
applicable, without asserting or creating dependent service releases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 01b7e044-dbb1-4361-b4b8-f9414b0f71e1

📥 Commits

Reviewing files that changed from the base of the PR and between ef4bd69 and 37fc06a.

📒 Files selected for processing (3)
  • docs/dev/github-release-process.md
  • tools/ci/github-release
  • tools/ci/test-github-release.py

Comment thread tools/ci/github-release Outdated
Comment thread tools/ci/github-release
balajinvda and others added 2 commits July 30, 2026 21:12
The first pass fanned out on any commit touching a framework path, so a
framework-only docs: or chore: commit would have cut a patch release for
every dependent Java service. That contradicts the release rules the
same script configures semantic-release with, where those types release
nothing.

Framework commits are now filtered through the configured RELEASE_RULES:
feat, fix, perf, and a breaking ! marker fan out; the other declared
types and a subject that is not a Conventional Commit do not. The rules
are read from RELEASE_RULES rather than restated, so the fan-out and the
per-service release rules cannot diverge.

Raised in review on PR #595.

Signed-off-by: Balaji Ganesan <bganesan@nvidia.com>
Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
resolve_release_outcome classified a run as "released" whenever the
output contained a next version, ignoring the exit code. A dry-run that
printed a version and then died was reported as a clean preview of a tag
the publish run may never create, and it never reached dry_run_failures.

A non-zero exit is now "unknown" regardless of what the run printed, so
it is surfaced in the dry-run preview summary. Publish mode is
unaffected: it already raises on a non-zero exit before the outcome is
classified. The preview warning no longer claims "no NEXT_VERSION",
which was inaccurate for a run that printed one before failing.

Raised in review on PR #595.

Signed-off-by: Balaji Ganesan <bganesan@nvidia.com>
Co-authored-by: Balaji Ganesan <bganesan@nvidia.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@sanjay-saxena sanjay-saxena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@balajinvda -- I read the description and it makes sense.

However, the PR description has incorrect versions. These are the current version numbers:

  • nvct-service-oss: 1.6.2
  • notary-service-oss: 1.8.1
  • nvcf-api-keys-service: 1.6.0

We should make sure that the next patch version or minor version change is based on these versions.

I could not find anywhere in the MR where we have hardcoded the base/current versions. Perhaps, we have them in the nvcf-internal repo where we the CI runs.

@balajinvda

Copy link
Copy Markdown
Contributor Author

@sanjay-saxena thanks. You are right that the description was misleading, and the answer to your question is: the base versions are not hardcoded anywhere, and they are not in another repo either. They come from Git tags in this repository.

Where the base version comes from

The chain is entirely inside tools/ci/github-release on this branch:

  1. tools/ci/github-release-subprojects.json gives each service its path.
  2. default_tag_format() (line 121) turns that path into <path>/v${version}; tag_prefix() (line 132) strips the placeholder.
  3. latest_service_tag() (line 282) runs git tag -l "<prefix>*" and returns the semver-max match, ordered by semverish_sort_key() (line 117).
  4. publish_framework_dependency_release() (line 598) takes that tag, extracts the version via version_from_tag() (line 159), and increments the patch with next_patch_version() (line 531).

So the base version is simply the highest existing tag on main. I also checked the alternatives: the three service pom.xml files are all 0.0.1-SNAPSHOT and are not a version source, none of these services has a VERSION file, and their entries in github-release-subprojects.json carry only id, path, and service_name with no version field. Nothing in this code path reads a version from outside this repository.

The description was wrong, the mechanism is not

The table I published was generated by running the new helpers against main, so it faithfully reported what the current anchors produce. The problem is that two of those anchors are themselves on the wrong line, and I presented the output as if it were the intended versions. I have corrected the description: the table is now explicitly labelled as a demonstration against the current, incorrect anchors, with a "correct line" column, and the Notes section now records where the base version comes from.

No code change is needed for this. Once the anchors are right, the same code computes the right numbers.

What has to happen before this can be enabled

One detail worth calling out, because it makes the ordering mandatory rather than cosmetic. semverish_sort_key() compares numerically per component, so adding a correct anchor next to the wrong one does not help:

  • with both v1.12.0 and a new v1.8.1 present, the max is still v1.12.0
  • with both v1.62.2 and a new v1.6.2 present, the max is still v1.62.2

Verified by running the function. So the wrong anchors have to be deleted, not just superseded:

  1. Delete src/control-plane-services/notary/v1.12.0 and src/control-plane-services/cloud-tasks/v1.62.2.
  2. Re-anchor notary at 1.8.1 and cloud-tasks at 1.6.2, matching the current versions you listed.
  3. Only then set NVCF_GITHUB_AUTO_TAGGING_ENABLED=true.

Merging this PR before that is safe. NVCF_GITHUB_AUTO_TAGGING_ENABLED defaults to false in .github/workflows/release-tags.yml, so the workflow runs dry-run only and creates no tag and no Release.

One thing I need you to confirm

You listed api-keys at 1.6.0, but main currently carries both src/control-plane-services/api-keys/v1.6.0 and src/control-plane-services/api-keys/v1.6.1. latest_service_tag() therefore returns v1.6.1 and the fan-out would cut 1.6.2, not 1.6.1.

Is v1.6.1 a real release that I should treat as current, or a stray anchor that should be deleted along with the other two? I did not want to guess either way.

@sanjay-saxena sanjay-saxena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@balajinvda - Latest maven-based tag for api-keys is 1.6.0 as shown here. It's not clear where 1.6.1 is coming from. I don't mind if we use 1.6.1 as the baseline for api-keys.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Java framework changes rebuild dependent services but release none of them

2 participants