feat(release): score dependency floors and cargo features in semver-level - #2506
Conversation
…evel
semver-level.sh combined cargo-semver-checks and cargo-public-api, which both
read rustdoc and neither of which reads a manifest. Two semver-relevant
manifest changes therefore came out as patch:
- a raised dependency requirement floor (`http = "1"` -> `"1.1"`), after which
a consumer pinned to the old version can no longer resolve this crate;
- an added cargo feature, for which cargo-semver-checks has no lint at all and
which adds no rustdoc item unless it happens to gate one.
Add two passes that read both facts through `cargo metadata`, so a requirement
behind `{ workspace = true }` is compared as the version it resolves to and the
implicit feature of an `optional = true` dependency is counted. Requirements
are keyed by `.rename` as well as name, kind and target, so a crate aliasing
two versions of one package does not match the second alias against the first
alias's baseline. Exclusive bounds carry a flag, so `>=1.2.3` -> `>1.2.3` is
seen as the narrowing it is.
Feature removals and default-set removals are scored as a backstop: for a
library crate cargo-semver-checks gets there first, but for a crate with no
library target it is skipped entirely and nothing else would notice.
A raised major floor is capped at minor here on purpose --
release-version-major-bumps.sh owns that decision and max_level() lets it win.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
semver-level.sh now scores a raised dependency requirement floor as a minor, so `build(deps): bump foo from 1.0 to 1.1` -- the conventional title for a dependency change -- would start failing the PR type rule. Conventional Commits defines `build` as changes to the build system or external dependencies, so a dependency bump under its own type has to be allowed to be a minor. Both `build:` commits in the last six months raised a real floor. ci/docs/style/test stay restricted; none of them raised a non-dev dependency floor over the same period. The breaking-change rule is untouched, so a `build` PR that breaks the API still needs `build!:`. Also correct the two messages that described the restriction as covering "API changes" only, now that a level can come from a manifest change with no API delta at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 054ba6d | Docs | View more details | Give us feedback! |
Artifact Size Benchmark Reportaarch64-alpine-linux-musl
aarch64-unknown-linux-gnu
libdatadog-x64-windows
libdatadog-x86-windows
x86_64-alpine-linux-musl
x86_64-unknown-linux-gnu
|
BenchmarksComparisonBenchmark execution time: 2026-09-21 08:15:57 Comparing candidate commit 054ba6d in PR branch Found 3 performance improvements and 1 performance regressions! Performance is the same for 162 metrics, 10 unstable metrics.
|
detect-changes selected a crate only when a file under its own directory changed, so a PR editing just the root manifest's [workspace.dependencies] selected nothing: has_rust_changes came out false and both the semver-check and validate jobs were skipped, leaving any PR title accepted. Since this workspace declares dependencies version-only at the root and members inherit them with `workspace = true`, that is the ordinary shape of a floor raise here, and the level semver-level.sh scores for it -- a minor -- was unreachable from CI. semver-level.sh grows a --list-affected mode naming every member whose dependency requirements or feature surface moved between two revisions, and detect-changes adds those crates to the ones it finds by path. It reuses manifest_facts_at_rev, now able to read the whole workspace in one extraction, so the facts the detector considers cannot drift from the facts the passes score -- a detector that considered fewer would silently reselect nothing. The mode compares from `git merge-base`, matching the `base...HEAD` the changed-file search uses: measured from the baseline tip instead, a branch behind it reports every crate that moved on the baseline since the fork, putting another branch's changes on this one's report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c49fb4f594
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Pass 2c keyed a feature's default membership on `default` listing it directly. That made a behaviour-preserving refactor -- `default = ["foo"]` becoming `default = ["bundle"]` with `bundle = ["foo"]` -- read as `foo` losing its default and score major, while the inverse, emptying a `bundle` that `default` still lists, is a real break that read as no change at all. Feature rows now carry the closure over the feature graph, which is what a consumer writing `default-features = true` actually gets. `dep:x` reaches no local feature, nor does `x?/f`; plain `x/f` reaches the implicit feature `x` exactly when `x` is an optional dependency. cargo-semver-checks resolves the closure too -- verified against 0.47.0 and 0.48.0, the pinned version, where feature_not_enabled_by_default passes the refactor and fails the inverse -- so keying on direct membership also put this pass at odds with the lint that backs it up. On libdd-common's real feature graph the two now agree on the same eight features. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A root [workspace.dependencies] entry that only gained a feature, or stopped disabling default features, left every inheriting member's facts identical. list_affected_crates named nobody, and with just the root manifest touched detect-changes found no crate by path either, so the semver jobs were skipped -- the same shape of gap as a floor raise selecting nothing. Dependency rows now carry the features the crate turns on and whether it takes the dependency's defaults. These are facts to be selected on, not scored: enabling a dependency feature can change the crate's own API through a re-export, but the manifest cannot say whether it did, and the passes that read rustdoc can. Pass 2b's projection stops at the requirement, so the floor comparison is untouched -- verified byte-identical across all 698 dependency rows, as are the 217 feature rows. The defaults column is spelled `== false` rather than `// true` because jq's alternative operator treats a false left-hand side as absent, which reported every dependency as taking the defaults and made the default-features case select nothing. Verified on a root serde_json edit, the only file changed: 0 crates selected before, all 20 inheritors after, for a added feature and for a default-features flip alike; an unchanged baseline still selects none, and scoring one of the 20 reports patch rather than a level of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
req_min tagged an exclusive bound with a fourth component instead of advancing it, so `>` was read as admitting the version it excludes. `>1.2` admits nothing below 1.3.0, which made `>=1.2.1` -> `>1.2` read as a lowering rather than the raise it is, and `>1.2.3` -> the equivalent `>=1.2.4` read as a raise. An exclusive bound is now advanced to the first version it does admit, which drops the ordering flag and returns version_gt to a plain triple. A pre-release bound is left unadvanced: `>1.2.3-rc.1` admits 1.2.3 itself, so the release version the reader already compares is that bound's exact floor and advancing would overstate it. A wildcard likewise, `>` not being able to carry one legally. req_min now agrees with semver 1.0.28's own VersionReq::matches on all 33 requirement forms checked -- every operator at one, two and three components, the four shapes this workspace uses, and both pre-release cases. Nothing moves on this workspace: its 149 distinct requirement strings all yield identical floors, every bound being inclusive with a full triple, and a root bytes 1.11 -> 1.12 raise still selects the 12 inheritors and scores libdd-capabilities minor on `bytes (normal): ^1.11 -> ^1.12`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I'm starting to look at at this, but there's something I don't yet understand in the description:
Why would raising a dependency from ^1 to ^1.1 be a minor or major change? Even if some dependent pins the version to Also I would expect the semver script to only check for breaking changes. The difference between minor and patch sounds rather subjective (in this case you could fix an urgent security issue by upgrading a dependency 1.0 -> 1.1 and making that a patch release sounds reasonable IMHO). But this one is not only for CI, it is for release? Does it decide if the release should be major or minor automatically, or does it just double check? |
It is based on an "agreement" regarding the bumps specified here
The script decides the level. It checks for breaking changes but if there are no breaking it tries to resolve if the changes correspond to a That said, this PR is kind of optional to increase |
|
Thanks for the detailed answer!
This section is about adding a new dependency, not bumping an existing one to a new minor version, right? In the end it's not very important as you mention it, but I don't know if I would flag a semver-compatible update as a necessary being at least a minor version bump.
I probably said it elsewhere already, but... rewrite it in Rust 😛 ? |
yannham
left a comment
There was a problem hiding this comment.
I can't say I'm very confident in my review, given it's a several hundred lines of complicated bash. But I guess the impact is limited (our own ci/release process) and the overall approach is ok
|
/merge |
|
View all feedbacks in Devflow UI.
It will be processed automatically as soon as GitHub reports it as mergeable. View in MergeQueue UI.
igor.unanua@datadoghq.com unqueued this merge request |
|
/code blockers |
|
View all feedbacks in Devflow UI.
Checking merge blockers for #2506...
No merge blockers detected. |
|
/merge |
|
View all feedbacks in Devflow UI.
It will be processed automatically as soon as GitHub reports it as mergeable. View in MergeQueue UI.
The expected merge time in
Tests failed on this commit 9ed82ba: What to do next?
|
What does this PR do?
semver-level.shcombined cargo-semver-checks and cargo-public-api. Both read rustdoc and neither reads a manifest, so two semver-relevant manifest changes came out aspatch:http = "1"→"1.1"): every rustdoc signature is byte-identical, yet a consumer pinned to the old version can no longer resolve the crate — conventionally a minor;Two passes are added, both reading the manifest through
cargo metadata:minorwhen the lowest version a requirement admits goes upminoron an added feature;majoron one removed, or dropped from the setdefaultreachesThe second commit is a consequence of the first:
buildmoves into the permissive PR-title type list, because scoring a dependency floor as minor would otherwise makebuild(deps): bump foo from 1.0 to 1.1— the conventional title for a dependency change — fail the type rule.The third commit is what lets CI reach any of it.
detect-changesselected a crate only when a file under its own directory changed, so a PR editing just the root manifest's[workspace.dependencies]selected nothing:has_rust_changescame outfalse, both thesemver-checkandvalidatejobs were skipped, and every PR title was accepted — adocs:title included. Since this workspace declares dependencies version-only at the root and members inherit them withworkspace = true, that is the ordinary shape of a floor raise here, which left the pass above unreachable from CI.semver-level.shgrows a--list-affectedmode naming every member whose dependency requirements, dependency feature selections or own feature surface moved between two revisions, anddetect-changesadds those crates to the ones it finds by path.The last three commits answer review findings against the manifest readers. None of them changes what any pass scores on this workspace today:
defaultlists a feature. So a behaviour-preserving refactor —default = ["foo"]becomingdefault = ["bundle"]withbundle = ["foo"]— read asfoolosing its default and scored major, while the inverse, emptying abundlethatdefaultstill lists, is a real break that read as no change at all. Rows now carry the closure over the feature graph, which is what a consumer writingdefault-features = trueactually gets.--list-affectednamed nobody and, with only the root manifest touched, the jobs were skipped again — the same shape of gap the third commit fixes for floors.req_minappended an exclusivity bit to the version a bound states rather than advancing past it, so>was read as admitting the version it excludes.>1.2admits nothing below 1.3.0, so>=1.2.1→>1.2read as a lowering rather than the raise it is; and>1.2.3→ the equivalent>=1.2.4read as a raise. Bounds now advance to the first version they admit, which drops the flag and returnsversion_gtto a plain triple.Motivation
Reviewing release proposal #2482 surfaced
libdd-capabilitiesproposed aspatch, when #2350 had moved itshttprequirement from^1to the workspace entry at^1.1. That is a minor, and nothing in the pipeline could see it: the version had moved into[workspace.dependencies], so even a textual diff of the crate's ownCargo.tomlshows only{ workspace = true }with no version in it.Additional Notes
Points reviewers may want to weigh:
cargo metadata, notCargo.toml. That is what resolves{ workspace = true }to the version the root manifest declares, and what surfaces the implicit feature anoptional = truedependency creates..renameas well as name/kind/target. A crate aliasing two versions of one package emits an identical name, kind and target for both; keyed without the alias, the second matches the first's baseline requirement and an unchanged pair reads as a raise.>excluding everything up to and including the components it states:>1.2.3starts at 1.2.4,>1.2at 1.3.0 and>1at 2.0.0. So>=1.2.3→>1.2.3reads as the narrowing it is,>=1.2.1→>1.2as the raise it is, and>1.2.3→ the equivalent>=1.2.4as no change at all. A pre-release bound is the exception and stays where it is:>1.2.3-rc.1admits 1.2.3 itself, so the release version this reader already compares is that bound's exact floor, and advancing it would overstate the requirement.req_minwas checked againstsemver1.0.28's ownVersionReq::matchesover 33 requirement forms — every operator at one, two and three components, the four shapes this workspace uses, and both pre-release cases — and agrees on all of them.minoron purpose. Whether that forces the dependent to major isrelease-version-major-bumps.sh's decision, made with the dependency graph this script cannot see;max_level()lets it win from there.feature_missingandfeature_not_enabled_by_defaultget there first; both were verified to fail the run rather than merely warn, using a two-crate repro against the pinned invocation. The closure above is what keeps the backstop from contradicting them: cargo-semver-checks resolves the default set transitively, so keyed on direct membership this pass would have reported major over a refactor the lint passes.--list-affectednames and assign no level of their own; 2b's key stops before them, so a feature move never reads as a requirement change.*_enables_featurelints' job) — that last reaches 2c only where it moves the setdefaultreaches.On the detection commit:
--list-affectedlives insemver-level.shrather than a script of its own, because it reusesmanifest_facts_at_rev— now able to read the whole workspace in one extraction — so what the detector considers a manifest fact cannot drift from what the passes score. A detector considering fewer facts silently selects nothing and reports "no change", which is the failure this commit fixes; there are no shell tests to catch that drift. 19 lines are new, the 51 they lean on are shared.git merge-base, matching thebase...HEADthe changed-file search uses. Measured from the baseline tip instead, a branch behind the baseline reports every crate that moved on the baseline since the fork, putting another branch's changes on this one's report — and since scoring uses the tip, a feature added on the baseline would read as a removal, i.e. a spurious major.detect-changeskeeps its ownpublishfilter, so apublish = falsemember such aslibdd-agent-clientshows up in the list and is dropped where that filter already lives.serde_json. That is deliberate: a changed floor or a newly enabled dependency feature can alter a crate's API through re-exports. If it bites, the cheap follow-up is a mode running only passes 2b/2c for those crates. Release-proposal PRs are unaffected — they carryskip-pr-title-semver-check, and each member'sversionis literal, so the path search already finds them.commits-since-release.shselects a crate's commits withgit log "$COMMIT_RANGE" -- "$CRATE_PATH", so a root-only floor raise leaves every inheriting crate with no commits andrelease-version-bumps.shdefers it. That is the right outcome, not a second instance of the bug above: the crate's code is unchanged, the requirement its published version states is still true of that code, and requirements are minimums, so a consumer combining it with a freshly published sibling that asks for the raised floor resolves the newer dependency anyway. Deferring moves neither the version nor the tag, so the raise stays in range and the crate's next release is still scored a minor for it — late, not wrong — while the case where a crate's code does need the raised version always arrives with a change to a file of its own, which the path search already finds. What is missing there is only the saying so: a deferred crate is recorded as levelnoneand reads as "nothing happened". A follow-up reports those moves at the deferral point instead of releasing 12 crates that needed nothing.Selection, each case a root-manifest edit and nothing else:
bytesfloor1.11→1.12(12 members inherit it)serde_jsongains a feature (20 members inherit it outside dev-dependencies)serde_jsonstops disabling default featuresScoring, over the same revisions, is unmoved by any of the three follow-ups: 2b's projection is byte-identical across all 698 dependency rows of the workspace, the 217 feature rows are byte-identical too, and all 149 distinct requirement strings in the workspace keep the floors they had — every bound here being inclusive with a full triple, the normalization has nothing to advance. So the floor and feature passes see exactly what they saw before. Both manifest passes also produce byte-identical output after the field shift in the shared reader (
bytes (normal): ^1.11 -> ^1.12;Cargo feature added / added: new-thing). Scoring one of the 20 selected crates end to end reportspatch— "No public API changes detected" — rather than a level invented from the new columns.The closure was checked against cargo-semver-checks 0.47.0 and 0.48.0, the pinned version, on a two-crate repro:
feature_not_enabled_by_defaultpasses the behaviour-preserving refactor and fails the inverse, exactly as the closure does, and onlibdd-common's real feature graph the pass and the lint name the same eight features. The refactor now scoresminorfor the genuinely new feature name rather thanmajor.Everything above runs under
RUSTUP_TOOLCHAIN=1.92.0as the workflow does.shellcheckon the script and on the extractedrun:block (actionlint's-e SC2086profile) reports nothing that was not already there.🤖 Generated with Claude Code