refactor(ci): release proposal dispatch wf - #2330
Conversation
compute_semver_results runs inside a command substitution, so the `exit` calls in its error paths only terminated that subshell. Every failure path -- an unparsed cargo-semver-checks result, an unexpected exit code, a cargo-public-api error -- left RESULT_JSON empty and still exited 0. release-proposal-dispatch.yml reads the level with `jq -r '.level'` from that output and passes it to `cargo release version -x $LEVEL` in a step that does not set -e, so the failure surfaced as a bump with an empty level rather than as a stopped release. Capture the substitution's status and propagate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- major-bumps-level.sh: drop the unused `level_rank` helper and the unused `FAIL` variable (SC2034). - check_cargo_metadata.sh: declare and assign separately so the command substitution's exit status is not masked by `local` (SC2155). No behaviour change. This lets `shellcheck --severity=warning` run over scripts/*.sh, which nothing covers today -- actionlint only shellchecks the `run:` blocks inside workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce-release.sh
commits-since-release.sh already worked out the commit range for its own
path-filtered `git log` and then discarded it; the "Release version bumps"
step recomputed the same three-way decision -- tag commit, merge-base when
the tag is not an ancestor of HEAD, or the parent of the oldest commit found
when the tag sits on unrelated history -- eighty lines later.
Export it instead, as two new fields on each crate:
range <start-sha>..<head-sha>, resolved to SHAs rather than tag
names. Empty when the crate has no previous release tag.
tag_commit the dereferenced tag, which the workflow still wants for its
`git branch --contains` diagnostic.
HEAD is resolved once, before the loop, so every crate's range ends at the
same commit. That is what ORIGINAL_HEAD existed for: the workflow captured
`git rev-parse HEAD` immediately after running the script so its own
recomputation would resolve against the same ref. With the range coming from
the script that coupling is gone, and ORIGINAL_HEAD drops out of the bumps
step (Generate CHANGELOGS still uses it for the commit list handed to the
verified push).
Also fixes a defect the move exposed. `git rev-parse <root-commit>^` exits
non-zero but still echoes "<sha>^" on stdout, so the `|| echo ""` guarding
the oldest-commit fallback could never fire and the range would start at a
ref that does not resolve. --verify makes the failure produce no output, as
that fallback was written to expect. The same line appears in Generate
CHANGELOGS, where it would hand git-cliff the bad range start; fixed there
too rather than leaving one of the two broken.
No change to the shape of api-changes.json: `range` was already carried
there and consumed as the git-cliff fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n api-changes.json pending-major-only.json had two readers: a `jq length` for the "no changes to push" guard, and a `jq -s '.[0] + .[1]'` in the very next step that merged it straight back into api-changes.json. Everything after that merge already told the two kinds of row apart by the "pending_release" field, never by which file they came from -- the seed filter, the PENDING check in the bump loop, and the del(.pending_release) in the rebuilt row. The split carried no information the flag did not. Write the pending candidates into api-changes.json directly, count them with a filter instead of a file length, and hand that one file to major-bumps-level.sh. Two things change: - api-changes.json is uploaded in the release-dispatch-data artifact and pending-major-only.json was not, so a crate that silently dropped out of a release left no trace in the artifact. It does now. - The audit input is in publication order with pending rows interleaved, rather than all released rows followed by all pending ones. The resulting api-changes-with-major-bumps.json is byte-identical (verified by replaying the seed and update-or-append merge over both orderings): the seed preserves the relative order of released rows, and promoted pending rows are still appended in the same sequence. Only the order of the "update version for $NAME with major bumps" commits differs, and publication order is the more sensible one. Also corrects the PR-body comment, which claimed api-changes-with-major-bumps holds the same crates as api-changes.json; it is the release set, i.e. that file minus the pending candidates that did not earn a major bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ase.sh `git tag -l "$NAME-v*" --sort=-v:refname | head -1` sat in the middle of the bump loop, one of the last pieces of tag resolution the workflow still did for itself. It answers a question about a crate's release history, which is what commits-since-release.sh is for, so export it as `latest_tag` and have the workflow read the field. Resolving it in the script also puts it on the same tag snapshot as `range` and `tag_commit`. The workflow computed it two steps later, after "Create a branch for the release proposal" runs another `git fetch --tags`; the skip rule and the commit range could in principle have been decided against different views of the tags. The script resolves it for every crate, not only those whose own tag exists, so a manifest version bumped without a release -- tag_exists false, older tags present -- is now visible in the artifact instead of being invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…branch list The bump loop ran `git branch --contains "$TAG_COMMIT"` and printed whichever local branches came back. Only the empty/non-empty distinction was ever acted on -- the names were never read by anything -- and it was the last git call the loop made about the tag. Export it from commits-since-release.sh as `tag_in_local_branch`, a JSON boolean, and have the workflow warn only when it is false. The branch names are deliberately not collected. A tagged commit no local branch contains is normal for a squash-merged release, but it is also what a tag pushed from an abandoned branch looks like, so it is worth saying out loud. The positive case no longer logs anything: "tag is in branches: main" was noise on every crate of every release. The field is false whenever there is no tag commit to ask about, and the workflow only reads it inside the tag_exists branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…umps.sh
The "Release version bumps" step was 142 lines of inline bash and the largest
untestable block in the workflow. It holds the whole release decision matrix:
defer a crate with no commits, skip one whose tag is not the latest (unless
hotfix or bypass), guard an initial release at 0.1.0, pick a level via
semver-level.sh, and build the api-changes row. None of it was reachable by a
test.
Lift it into scripts/release-version-bumps.sh, verbatim: same order, same
messages, same output. The four moves before this one are what made it small
enough to be worth doing -- the loop no longer resolves anything from git, it
reads tag_commit, range, latest_tag and tag_in_local_branch as fields, so the
script's external surface is just cargo-release and semver-level.sh.
semver-level.sh is resolved as a sibling of this script rather than through
WORKFLOW_SCRIPTS_ROOT, which guarantees the two come from the same pinned
snapshot instead of relying on the variable being right.
Verified by extracting the previous step body from git and running both against
identical fixture repositories, across three scenarios (plain, --hotfix,
--bypass-standard-checks) and all four crate paths -- normal release, skipped
because a newer tag exists, initial release, deferred with no commits. The
resulting api-changes.json and the git commits cargo-release produced are
identical in every case.
Three deliberate differences:
- set -euo pipefail. The step ran under GitHub's default `bash -e {0}`, so
errexit was on but pipefail and nounset were not: a failing
`jq -c '.[]' commits-by-crate.json` left the loop with no input and the step
exited 0, releasing nothing.
- A semver-level.sh failure now prints what went wrong. The output was captured
with 2>&1 into a variable that errexit then discarded, so the run aborted
with nothing in the log.
- is_hotfix and bypass_standard_checks reach the shell through env: and become
flags, instead of being interpolated into the script body as ${{ }}.
What stays in the workflow: the shallow-fetch guard and the no-changes-to-push
check, which needs git state the script does not own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the Release version bumps step: the value reaches the shell as an environment variable instead of being interpolated into the script body, which is the pattern that keeps expression content out of the code being parsed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or-bumps.sh Same lift as release-version-bumps.sh, for the step that audits every release candidate for direct libdd-* dependencies that went to a new major and promotes the crates that need it. 90 lines of inline bash become four. Transposed verbatim: the throwaway-worktree audit, the seed, the promote loop and the update-or-append merge all keep their order, their messages and their output. major-bumps-level.sh is resolved as a sibling of this script rather than through WORKFLOW_SCRIPTS_ROOT, so the two come from the same pinned snapshot. The intermediate pre-commit audit file moves from a fixed /tmp path to mktemp with a trap; it was never uploaded as an artifact and nothing else read it. It is still cat'd when the audit fails, which is the only time anyone wants it. Verified by extracting the previous step body from git and running both against identical fixture workspaces, over all four outcomes: a crate already at major left alone, a released crate below major promoted in place, a pending no-commit crate pulled into the release and appended, and a crate with no libdd-* dependency untouched. The resulting JSON, the cargo-release commits and the worktree cleanup are identical. Unlike the version-bumps script this one needs no test doubles at all -- major-bumps-level.sh only runs cargo metadata, with no compilation -- so the tests for it can exercise the whole thing for real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hrough env
Its one use, the no-changes-to-push guard, now reads an environment variable
instead of an expression interpolated into the script body. No ${{ }} is left
inside that step's shell.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…angelogs.sh
The last large inline block in the workflow: 112 lines become two. Transposed
verbatim -- the four paths a crate can take, their order, their messages and the
commits they produce are unchanged.
Verified by extracting the previous step body from git and running both against
identical fixture repositories, covering every path in one pass:
libdd-a commits present git-cliff two-pass, prepended above the
existing release section
libdd-b no commits, dep went major minimal entry with the dependency lines,
header matching git-cliff's format
libdd-c no commits, nothing forced no entry, no file
libdd-d initial release, file kept left untouched
libdd-e initial release, no file minimal "Initial release." created
The resulting CHANGELOG.md files are identical once commit SHAs (which differ
per fixture instance) are normalised, and both runs produce the same three
commits.
Two small changes: mktemp honours TMPDIR instead of hardcoding /tmp, and the
compare-link base is a --remote-url option defaulting to the value that was
previously inlined, so the script can be exercised outside this repository.
What stays in the workflow: reading release_head_sha, the no-changes-to-push
guard, and the commit list handed to commit-headless through GITHUB_OUTPUT --
all workflow state the script does not own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three scripts lifted out of release-proposal-dispatch.yml had no owner. CODEOWNERS has no catch-all pattern and the validator runs with the `notowned` experimental check, so unowned files fail CI. Owned by libdatadog-core, matching the release scripts they sit beside and call into -- commits-since-release.sh, major-bumps-level.sh, publication-order.sh and semver-level.sh -- rather than the .github/ rule that covered them while they were still inline in the workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
|
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 75afa6f | Docs | Datadog PR Page | Give us feedback! |
BenchmarksComparisonBenchmark execution time: 2026-08-07 10:56:36 Comparing candidate commit 75afa6f in PR branch Found 4 performance improvements and 0 performance regressions! Performance is the same for 136 metrics, 0 unstable metrics.
|
What does this PR do?
Move inline bash script in the workflow into separated scripts:
semver-level.sh: now handles correctly failurescommits-since-release.sh: now includes 'tag_in_local_branch', 'latest_tag' and 'range' in the output json needed by release-version-bumps.shmajor-bumps-level.sh: removed not used codecheck_cargo_metadata.sh: clean uprelease-version-bumps.sh: Replaces 'Release version bumps' steprelease-version-major-bumps.sh: Replaces 'Update version for crates with libdd-* …' steprelease-generate-changelogs.sh: Replaces 'Generate CHANGELOGS' stepChanges
set -euo pipefailin the extracted scripts. The steps ran under GitHub's default bash -e {0}: errexit on, but pipefail and nounset off. Concretely, a failingjq -c '.[]' /tmp/commits-by-crate.jsonleft the loop with no input and the step exited 0, releasing nothing.semver-level.shfailure now prints why. Its output was captured with 2>&1 into a variable that errexit then discarded.api-changes.jsonnow includes the deferred crates. It is in the release-dispatch-data artifacts.