From 5ab513395400e14211803b5fe3ce2f55c1bed64f Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 13:14:16 +0200 Subject: [PATCH 01/14] fix(release): propagate semver-level.sh tool failures to the caller 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) --- scripts/semver-level.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/semver-level.sh b/scripts/semver-level.sh index abfd530616..069b1113a4 100755 --- a/scripts/semver-level.sh +++ b/scripts/semver-level.sh @@ -261,8 +261,17 @@ compute_semver_results() { '{"name": $name, "level": $level, "reason": $reason, "details": $details}')" } -# Run the computation and capture JSON output +# Run the computation and capture JSON output. +# compute_semver_results runs in a command substitution, so the `exit` calls in its +# error paths only terminate that subshell. Propagate the status explicitly, otherwise +# a tool failure leaves RESULT_JSON empty and the script still exits 0 — the caller +# then reads an empty semver level and bumps the crate with whatever cargo-release +# makes of it. RESULT_JSON=$(compute_semver_results "$CRATE" "$BASE_REF" "$CURRENT_REF") +COMPUTE_EXIT_CODE=$? +if [[ $COMPUTE_EXIT_CODE -ne 0 ]]; then + exit $COMPUTE_EXIT_CODE +fi # Output JSON to stdout (captured by workflow) echo "$RESULT_JSON" From 9055a97954f75568d82c4843967974075fa24473 Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 13:14:22 +0200 Subject: [PATCH 02/14] chore(scripts): clear shellcheck warnings in the release scripts - 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) --- scripts/check_cargo_metadata.sh | 5 +++-- scripts/major-bumps-level.sh | 10 ---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/scripts/check_cargo_metadata.sh b/scripts/check_cargo_metadata.sh index f2e17f0637..52074db32a 100755 --- a/scripts/check_cargo_metadata.sh +++ b/scripts/check_cargo_metadata.sh @@ -142,8 +142,9 @@ check_internal_dependencies() { # Main validation function validate_cargo_toml() { local file="$1" - local crate_dir=$(dirname "$file") - local crate_name=$(basename "$crate_dir") + local crate_dir crate_name + crate_dir=$(dirname "$file") + crate_name=$(basename "$crate_dir") local has_error=0 # Skip the root workspace Cargo.toml diff --git a/scripts/major-bumps-level.sh b/scripts/major-bumps-level.sh index 6bc333a0f4..136578ceb7 100755 --- a/scripts/major-bumps-level.sh +++ b/scripts/major-bumps-level.sh @@ -21,15 +21,6 @@ usage() { API_JSON=$1 [[ -f "$API_JSON" ]] || { echo "Not a file: $API_JSON" >&2; exit 2; } -level_rank() { - case "${1:-}" in - patch) echo 0 ;; - minor) echo 1 ;; - major) echo 2 ;; - *) echo -1 ;; - esac -} - libdd_deps_for_crate() { local manifest crate manifest=$1 @@ -90,7 +81,6 @@ trap cleanup EXIT n=$(jq length "$API_JSON") OUT=$(mktemp) echo '[]' >"$OUT" -FAIL=0 for ((i = 0; i < n; i++)); do row=$(jq -c ".[$i]" "$API_JSON") From a96b1662754c256d051a0724cf15b15689a14eda Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 13:30:47 +0200 Subject: [PATCH 03/14] refactor(release): move the commit range calculation into commits-since-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 .., 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 ^` exits non-zero but still echoes "^" 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) --- .../workflows/release-proposal-dispatch.yml | 53 +++++----------- scripts/commits-since-release.sh | 62 +++++++++++++++++-- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index ce454a86c7..e1fd5916c4 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -434,8 +434,10 @@ jobs: # Get commits since release for each crate and save to file "${WORKFLOW_SCRIPTS_ROOT}/commits-since-release.sh" "$(cat /tmp/crates.json)" > /tmp/commits-by-crate.json - # Capture ephemeral release branch tip now. Use this in Release version bumps - # so tag/merge-base resolution uses the same ref the script used. + # Capture the ephemeral release branch tip now: later steps run from the proposal + # branch, whose HEAD has moved on. commits-since-release.sh already ends every + # crate's `range` at this same commit; this is for the steps that need the tip on + # its own (the commit list handed to the verified push). git rev-parse HEAD > /tmp/release_head_sha echo "Release branch HEAD (saved for later): $(cat /tmp/release_head_sha)" echo "release_head_sha=$(cat /tmp/release_head_sha)" >> "$GITHUB_OUTPUT" @@ -483,11 +485,6 @@ jobs: # Crates with no commits of their own are not released here, but recorded as candidates: echo "[]" > /tmp/pending-major-only.json - # Use release branch tip from when we ran commits-since-release (same ref the script used). - # Avoids tag/merge-base resolution failures after switching to the new proposal branch. - ORIGINAL_HEAD=$(cat /tmp/release_head_sha) - echo "ORIGINAL_HEAD: $ORIGINAL_HEAD" - BRANCH_NAME="${{ steps.proposal-branch.outputs.branch_name }}" # iterate over the commits and execute cargo release for each crate @@ -515,40 +512,19 @@ jobs: fi if [ "$TAG_EXISTS" = "true" ]; then - # Explicitly dereference annotated tags to their underlying commit. - # Several git commands do not consistently dereference annotated tag objects - # across all git versions. - TAG_COMMIT=$(git rev-parse "${TAG}^{}" 2>/dev/null || echo "") - if [ -z "$TAG_COMMIT" ]; then + # commits-since-release.sh resolved the tag (dereferencing annotated tags) and + # worked out the range the commits above were taken from: the tag commit, the + # 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. Reuse its answer + # rather than recomputing it here against a HEAD that has since moved. + TAG_COMMIT=$(echo "$crate" | jq -r '.tag_commit') + RANGE=$(echo "$crate" | jq -r '.range') + if [ -z "$TAG_COMMIT" ] || [ -z "$RANGE" ]; then echo "ERROR: Could not dereference tag $TAG to a commit" >&2 exit 1 fi - - RANGE="$TAG_COMMIT..$ORIGINAL_HEAD" echo "Using $RANGE as range (tag: $TAG)" - if git merge-base --is-ancestor "$TAG_COMMIT" "$ORIGINAL_HEAD" 2>/dev/null; then - echo " Tag $TAG is ancestor of HEAD" - else - MERGE_BASE=$(git merge-base "$TAG_COMMIT" "$ORIGINAL_HEAD" 2>/dev/null || echo "") - if [ -n "$MERGE_BASE" ]; then - RANGE="$MERGE_BASE..$ORIGINAL_HEAD" - echo " Tag $TAG is NOT ancestor of HEAD, using merge-base: $RANGE" - else - # No common ancestor, tag is on unrelated history. Derive the range start from - # the parent of the oldest commit found by commits-since-release.sh. That way - # git can compute TREESAME correctly and the path filter won't include unrelated commits. - OLDEST_COMMIT=$(echo "$COMMITS" | jq -r '.[-1].hash // empty') - OLDEST_PARENT=$(git rev-parse "${OLDEST_COMMIT}^" 2>/dev/null || echo "") - if [ -n "$OLDEST_PARENT" ]; then - RANGE="$OLDEST_PARENT..$ORIGINAL_HEAD" - echo " No common ancestor with tag $TAG: using parent of oldest commit as range start: $RANGE" - else - echo " WARNING: Could not find merge-base for tag $TAG, using $RANGE" - fi - fi - fi - BRANCHES=$(git branch --contains "$TAG_COMMIT" 2>/dev/null || echo "") if [ -n "$BRANCHES" ]; then echo "Tag $TAG is in branches: $BRANCHES" @@ -816,7 +792,10 @@ jobs: # go through git-cliff filtering process. NEWEST_COMMIT=$(echo "$COMMITS" | jq -r '.[0].hash // empty') OLDEST_COMMIT=$(echo "$COMMITS" | jq -r '.[-1].hash // empty') - OLDEST_PARENT=$(git rev-parse "${OLDEST_COMMIT}^" 2>/dev/null || echo "") + # --verify matters: plain `git rev-parse ^` exits non-zero but still + # echoes "^" on stdout, so the `|| echo ""` fallback to $RANGE would never + # fire and git-cliff would be handed a range start that does not resolve. + OLDEST_PARENT=$(git rev-parse --verify "${OLDEST_COMMIT}^" 2>/dev/null || echo "") if [ -n "$OLDEST_PARENT" ] && [ -n "$NEWEST_COMMIT" ]; then COMMITS_RANGE="$OLDEST_PARENT..$NEWEST_COMMIT" else diff --git a/scripts/commits-since-release.sh b/scripts/commits-since-release.sh index 8b5a0a9f32..eba3193a12 100755 --- a/scripts/commits-since-release.sh +++ b/scripts/commits-since-release.sh @@ -67,7 +67,14 @@ ${arg#--exclude=}" echo " ./commits-since-release.sh --exclude='^chore:' --exclude='^ci:' \"\$JSON\"" echo "" echo "Output JSON format:" - echo ' [{"name":"crate-name","version":"1.0.0","tag":"crate-name-v1.0.0","tag_exists":true,"commits":[...]}]' + echo ' [{"name":"crate-name","version":"1.0.0","path":"crate-name","tag":"crate-name-v1.0.0",' + echo ' "tag_exists":true,"tag_ancestor":"true","tag_commit":"",' + echo ' "range":"..","commits":[...]}]' + echo "" + echo ' "range" is the commit range the listed commits were taken from, resolved to' + echo ' SHAs: the tag commit, or the merge-base when the tag is not an ancestor of' + echo ' HEAD, or the parent of the oldest commit found when there is no common' + echo ' ancestor at all. Empty when the crate has no previous release tag.' exit 0 ;; -*) @@ -99,6 +106,10 @@ METADATA=$(cargo metadata --format-version=1 --no-deps 2>/dev/null) # Get workspace root (for determining crate paths) WORKSPACE_ROOT=$(echo "$METADATA" | jq -r '.workspace_root' || pwd) +# Resolve HEAD once, so every crate's exported range ends at the same commit and callers +# can reuse the range later without re-resolving HEAD (which may have moved on by then). +HEAD_COMMIT=$(git rev-parse HEAD) + log_verbose() { if [ "$VERBOSE" = true ]; then echo "$@" >&2 @@ -156,16 +167,23 @@ while read -r crate; do # Check if tag exists TAG_EXISTS=false TAG_ANCESTOR="unknown" + TAG_COMMIT="" + RANGE="" + RANGE_START="" COMMITS_JSON="[]" - + if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then TAG_EXISTS=true log_verbose " Tag exists, finding commits since $TAG..." - + # Check if tag is an ancestor of HEAD (i.e., release was merged back to main) # If not, use merge-base to find the common ancestor. # Explicitly dereference annotated tags to their underlying commit: git merge-base does # not consistently dereference annotated tag objects across all git versions. + # + # RANGE_START is the same decision expressed as a commit SHA, and becomes the + # exported `range`. Callers need a range they can hand to other git tooling, so + # it is resolved to SHAs rather than left as a tag name. TAG_COMMIT=$(git rev-parse "${TAG}^{}" 2>/dev/null || echo "") if [ -z "$TAG_COMMIT" ]; then COMMIT_RANGE="$TAG..HEAD" @@ -174,20 +192,24 @@ while read -r crate; do elif git merge-base --is-ancestor "$TAG_COMMIT" HEAD 2>/dev/null; then COMMIT_RANGE="$TAG..HEAD" TAG_ANCESTOR="true" + RANGE_START="$TAG_COMMIT" log_verbose " Tag is ancestor of HEAD, using $COMMIT_RANGE" else MERGE_BASE=$(git merge-base "$TAG_COMMIT" HEAD 2>/dev/null || echo "") if [ -n "$MERGE_BASE" ]; then COMMIT_RANGE="$MERGE_BASE..HEAD" TAG_ANCESTOR="$MERGE_BASE" + RANGE_START="$MERGE_BASE" log_verbose " Tag is NOT ancestor of HEAD, using merge-base: $COMMIT_RANGE" else + # Tag is on unrelated history. RANGE_START is derived from the commits + # below, once we know which ones there are. COMMIT_RANGE="$TAG..HEAD" TAG_ANCESTOR="no merge-base" log_verbose " WARNING: Could not find merge-base, using $TAG..HEAD" fi fi - + # Get commits since tag that affect this crate's directory # Use ASCII unit separator (0x1F) as delimiter - won't appear in commit messages COMMITS_JSON="[" @@ -216,9 +238,37 @@ while read -r crate; do done < <(git log "$COMMIT_RANGE" --format="%H%x1F%s%x1F%an%x1F%aI" -- "$CRATE_PATH" 2>/dev/null || true) COMMITS_JSON+="]" - + COMMIT_COUNT=$(echo "$COMMITS_JSON" | jq 'length') log_verbose " Found $COMMIT_COUNT commits since $TAG" + + # No common ancestor with the tag: `$TAG..HEAD` spans HEAD's entire history, which + # is fine for the path-filtered log above but far too wide to hand to git-cliff. + # Start the exported range at the parent of the oldest commit we actually found, + # so it covers those commits and nothing else. + if [ -z "$RANGE_START" ] && [ -n "$TAG_COMMIT" ]; then + OLDEST_COMMIT=$(echo "$COMMITS_JSON" | jq -r '.[-1].hash // empty') + OLDEST_PARENT="" + if [ -n "$OLDEST_COMMIT" ]; then + # --verify matters: plain `git rev-parse ^` exits non-zero but + # still echoes "^" on stdout, so the `|| echo ""` fallback would never + # fire and the range would start at a ref that does not resolve. + OLDEST_PARENT=$(git rev-parse --verify "${OLDEST_COMMIT}^" 2>/dev/null || echo "") + fi + if [ -n "$OLDEST_PARENT" ]; then + RANGE_START="$OLDEST_PARENT" + log_verbose " No common ancestor with $TAG: range starts at the parent of the oldest commit" + else + RANGE_START="$TAG_COMMIT" + log_verbose " WARNING: Could not derive a range start for $TAG, falling back to the tag commit" + fi + fi + + # Empty only when the tag could not be dereferenced at all. + if [ -n "$RANGE_START" ]; then + RANGE="${RANGE_START}..${HEAD_COMMIT}" + log_verbose " Range: $RANGE" + fi else log_verbose " Tag does NOT exist - no previous release found" fi @@ -230,7 +280,7 @@ while read -r crate; do OUTPUT_JSON+="," fi - OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"commits\":$COMMITS_JSON}" + OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"tag_commit\":\"$TAG_COMMIT\",\"range\":\"$RANGE\",\"commits\":$COMMITS_JSON}" done < <(echo "$INPUT_JSON" | jq -c '.[]') From 643a5d9614695f993444b1512244c07c59b7cdbd Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 15:28:32 +0200 Subject: [PATCH 04/14] refactor(release): drop pending-major-only.json in favour of a flag on 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) --- .../workflows/release-proposal-dispatch.yml | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index e1fd5916c4..83b34b5036 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -479,12 +479,11 @@ jobs: git fetch --unshallow fi - # Initialize results array + # Initialize results array. It holds one row per candidate crate: those released + # here, and those with no commits of their own, which carry "pending_release": + # "true" and are only released if the libdd-* major-bump check pulls them back in. echo "[]" > /tmp/api-changes.json - # Crates with no commits of their own are not released here, but recorded as candidates: - echo "[]" > /tmp/pending-major-only.json - BRANCH_NAME="${{ steps.proposal-branch.outputs.branch_name }}" # iterate over the commits and execute cargo release for each crate @@ -507,16 +506,11 @@ jobs: --arg version "$VERSION" \ --arg path "$CRATE_PATH" \ '. += [{"name": $name, "level": "none", "tag": $tag, "prev_tag": $tag, "version": $version, "range": "", "commits": [], "path": $path, "initial_release": "false", "pending_release": "true"}]' \ - /tmp/pending-major-only.json > /tmp/pending-major-only.tmp && mv /tmp/pending-major-only.tmp /tmp/pending-major-only.json + /tmp/api-changes.json > /tmp/api-changes.tmp && mv /tmp/api-changes.tmp /tmp/api-changes.json continue fi if [ "$TAG_EXISTS" = "true" ]; then - # commits-since-release.sh resolved the tag (dereferencing annotated tags) and - # worked out the range the commits above were taken from: the tag commit, the - # 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. Reuse its answer - # rather than recomputing it here against a HEAD that has since moved. TAG_COMMIT=$(echo "$crate" | jq -r '.tag_commit') RANGE=$(echo "$crate" | jq -r '.range') if [ -z "$TAG_COMMIT" ] || [ -z "$RANGE" ]; then @@ -602,7 +596,7 @@ jobs: # Check if there are commits to push or pending if git diff --quiet "${{ steps.ephemeral-branch.outputs.ephemeral_branch }}"; then - PENDING_COUNT=$(jq 'length' /tmp/pending-major-only.json) + PENDING_COUNT=$(jq '[.[] | select(.pending_release == "true")] | length' /tmp/api-changes.json) if [ "$PENDING_COUNT" -gt 0 ]; then echo "No direct version bumps yet, but $PENDING_COUNT crate(s) are pending libdd-* major-bump evaluation; continuing." else @@ -620,13 +614,11 @@ jobs: set -euo pipefail BRANCH_NAME="${{ steps.proposal-branch.outputs.branch_name }}" - # Audit input: crates released in the previous step (api-changes.json) plus the pending - # no-commit candidates. The pending rows carry "pending_release": "true" so we can tell - # them apart below; every row is checked the same way for direct libdd-* major bumps. - jq -s '.[0] + .[1]' /tmp/api-changes.json /tmp/pending-major-only.json > /tmp/major-bumps-input.json - echo "Major-bump audit input:" - jq . /tmp/major-bumps-input.json - + # Audit input is api-changes.json: every candidate from the previous step -- the crates + # released there and the pending no-commit candidates, which carry "pending_release": + # "true" so we can tell them apart below. Every row is checked the same way for direct + # libdd-* major bumps; only what happens to the result differs. + # # Run the audit in a throwaway worktree so extra worktrees / cargo metadata do not touch # the job checkout. Check it out at the proposal branch tip (HEAD) — the released ref plus # this run's version bumps from the previous step. This is deliberate on both ends: @@ -640,7 +632,7 @@ jobs: git worktree add --detach "$MAJOR_BUMPS_WT" "$PROPOSAL_SHA" set +e - ( cd "$MAJOR_BUMPS_WT" && "${WORKFLOW_SCRIPTS_ROOT}/major-bumps-level.sh" /tmp/major-bumps-input.json ) \ + ( cd "$MAJOR_BUMPS_WT" && "${WORKFLOW_SCRIPTS_ROOT}/major-bumps-level.sh" /tmp/api-changes.json ) \ > /tmp/api-changes-with-major-bumps-pre-commit.json MB_RC=$? git worktree remove --force "$MAJOR_BUMPS_WT" || true @@ -942,7 +934,9 @@ jobs: NON_DEFAULT="${NON_DEFAULT}"$'\n\n' fi - # PR body from api-changes-with-major-bumps.json (same crates as api-changes.json; tags/versions updated after libdd major bumps). + # PR body from api-changes-with-major-bumps.json: the crates actually being released, + # i.e. api-changes.json minus the pending candidates that did not earn a major bump, + # with tags/versions updated for those that did. # Note: read returns 1 when it reaches EOF, which is expected for heredocs read -r -d '' JQ_FILTER << 'EOF' || true [ $api[0][] From 58f113014c3d21d352c37ad8f86bde834b8b8d81 Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 16:33:15 +0200 Subject: [PATCH 05/14] refactor(release): move the latest-tag lookup into commits-since-release.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) --- .github/workflows/release-proposal-dispatch.yml | 4 ++-- scripts/commits-since-release.sh | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 83b34b5036..0bb7f0c215 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -526,8 +526,8 @@ jobs: echo "Tag $TAG (commit $TAG_COMMIT) is not in any local branch (normal for squash-merged releases)" fi - # if there is a tag more recent than $TAG, continue the loop - LATEST_TAG=$(git tag -l "$TAG_PREFIX*" --sort=-v:refname | head -1) + # if there is a tag more recent than $TAG, continue the loop. + LATEST_TAG=$(echo "$crate" | jq -r '.latest_tag') if [ "$LATEST_TAG" != "$TAG" ]; then echo "Tag $TAG is not the latest. Latest is: $LATEST_TAG. main branch has the latest release for $NAME" diff --git a/scripts/commits-since-release.sh b/scripts/commits-since-release.sh index eba3193a12..25978083d8 100755 --- a/scripts/commits-since-release.sh +++ b/scripts/commits-since-release.sh @@ -69,7 +69,11 @@ ${arg#--exclude=}" echo "Output JSON format:" echo ' [{"name":"crate-name","version":"1.0.0","path":"crate-name","tag":"crate-name-v1.0.0",' echo ' "tag_exists":true,"tag_ancestor":"true","tag_commit":"",' - echo ' "range":"..","commits":[...]}]' + echo ' "latest_tag":"crate-name-v1.0.0","range":"..","commits":[...]}]' + echo "" + echo ' "latest_tag" is the highest release tag that exists for the crate, which is not' + echo ' necessarily "tag": the manifest version lags behind when a release was cut' + echo ' elsewhere. Empty when the crate was never released.' echo "" echo ' "range" is the commit range the listed commits were taken from, resolved to' echo ' SHAs: the tag commit, or the merge-base when the tag is not an ancestor of' @@ -164,6 +168,14 @@ while read -r crate; do log_verbose " Crate path: $CRATE_PATH" + # Highest release tag that exists for this crate, which is not necessarily $TAG: the + # manifest version can lag behind the tags when a release was cut elsewhere (a hotfix + # branch, or a release already merged to main). Empty when the crate was never released. + # The `-v` in the glob keeps sibling crates out: libdd-common-v* cannot match + # libdd-common-ffi-v1.0.0. + LATEST_TAG=$(git tag -l "${NAME}-v*" --sort=-v:refname | head -1) + log_verbose " Latest tag: ${LATEST_TAG:-}" + # Check if tag exists TAG_EXISTS=false TAG_ANCESTOR="unknown" @@ -280,7 +292,7 @@ while read -r crate; do OUTPUT_JSON+="," fi - OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"tag_commit\":\"$TAG_COMMIT\",\"range\":\"$RANGE\",\"commits\":$COMMITS_JSON}" + OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"tag_commit\":\"$TAG_COMMIT\",\"latest_tag\":\"$LATEST_TAG\",\"range\":\"$RANGE\",\"commits\":$COMMITS_JSON}" done < <(echo "$INPUT_JSON" | jq -c '.[]') From b3626d8cf43c99289277d698fb4c875e021de1a8 Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 16:50:16 +0200 Subject: [PATCH 06/14] refactor(release): export tag reachability as a boolean instead of a 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) --- .../workflows/release-proposal-dispatch.yml | 7 ++----- scripts/commits-since-release.sh | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 0bb7f0c215..6d61df05d0 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -519,11 +519,8 @@ jobs: fi echo "Using $RANGE as range (tag: $TAG)" - BRANCHES=$(git branch --contains "$TAG_COMMIT" 2>/dev/null || echo "") - if [ -n "$BRANCHES" ]; then - echo "Tag $TAG is in branches: $BRANCHES" - else - echo "Tag $TAG (commit $TAG_COMMIT) is not in any local branch (normal for squash-merged releases)" + if [ "$(echo "$crate" | jq -r '.tag_in_local_branch')" != "true" ]; then + echo "Warning: tag $TAG (commit $TAG_COMMIT) is not in any local branch (normal for squash-merged releases)" fi # if there is a tag more recent than $TAG, continue the loop. diff --git a/scripts/commits-since-release.sh b/scripts/commits-since-release.sh index 25978083d8..1fd1631fd0 100755 --- a/scripts/commits-since-release.sh +++ b/scripts/commits-since-release.sh @@ -69,7 +69,11 @@ ${arg#--exclude=}" echo "Output JSON format:" echo ' [{"name":"crate-name","version":"1.0.0","path":"crate-name","tag":"crate-name-v1.0.0",' echo ' "tag_exists":true,"tag_ancestor":"true","tag_commit":"",' - echo ' "latest_tag":"crate-name-v1.0.0","range":"..","commits":[...]}]' + echo ' "tag_in_local_branch":true,"latest_tag":"crate-name-v1.0.0",' + echo ' "range":"..","commits":[...]}]' + echo "" + echo ' "tag_in_local_branch" is false when no local branch contains the tagged commit,' + echo ' which is normal for squash-merged releases. Always false when there is no tag.' echo "" echo ' "latest_tag" is the highest release tag that exists for the crate, which is not' echo ' necessarily "tag": the manifest version lags behind when a release was cut' @@ -180,6 +184,7 @@ while read -r crate; do TAG_EXISTS=false TAG_ANCESTOR="unknown" TAG_COMMIT="" + TAG_IN_LOCAL_BRANCH=false RANGE="" RANGE_START="" COMMITS_JSON="[]" @@ -222,6 +227,16 @@ while read -r crate; do fi fi + # Is the tagged commit reachable from any local branch? Squash-merged releases leave + # the tag on history no branch points at, which is normal but worth surfacing: it is + # also what a tag pushed from an abandoned branch looks like. Only the yes/no matters, + # so the branch names are deliberately not collected. + if [ -n "$TAG_COMMIT" ] && git branch --contains "$TAG_COMMIT" 2>/dev/null | grep -q .; then + TAG_IN_LOCAL_BRANCH=true + else + log_verbose " WARNING: $TAG ($TAG_COMMIT) is not in any local branch" + fi + # Get commits since tag that affect this crate's directory # Use ASCII unit separator (0x1F) as delimiter - won't appear in commit messages COMMITS_JSON="[" @@ -292,7 +307,7 @@ while read -r crate; do OUTPUT_JSON+="," fi - OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"tag_commit\":\"$TAG_COMMIT\",\"latest_tag\":\"$LATEST_TAG\",\"range\":\"$RANGE\",\"commits\":$COMMITS_JSON}" + OUTPUT_JSON+="{\"name\":\"$NAME\",\"version\":\"$VERSION\",\"path\":\"$CRATE_PATH\",\"tag\":\"$TAG\",\"tag_exists\":$TAG_EXISTS,\"tag_ancestor\":\"$TAG_ANCESTOR\",\"tag_commit\":\"$TAG_COMMIT\",\"tag_in_local_branch\":$TAG_IN_LOCAL_BRANCH,\"latest_tag\":\"$LATEST_TAG\",\"range\":\"$RANGE\",\"commits\":$COMMITS_JSON}" done < <(echo "$INPUT_JSON" | jq -c '.[]') From 925403c59a0345938d7ad1cd446f16f27a0553b3 Mon Sep 17 00:00:00 2001 From: iunanua Date: Thu, 6 Aug 2026 17:01:52 +0200 Subject: [PATCH 07/14] refactor(release): move the version-bumps loop into release-version-bumps.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) --- .../workflows/release-proposal-dispatch.yml | 131 ++---------- scripts/release-version-bumps.sh | 195 ++++++++++++++++++ 2 files changed, 210 insertions(+), 116 deletions(-) create mode 100755 scripts/release-version-bumps.sh diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 6d61df05d0..81a7e8066f 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -469,8 +469,13 @@ jobs: - name: Release version bumps id: release-version-bumps + env: + PROPOSAL_BRANCH: ${{ steps.proposal-branch.outputs.branch_name }} + EPHEMERAL_BRANCH: ${{ steps.ephemeral-branch.outputs.ephemeral_branch }} + IS_HOTFIX: ${{ steps.ephemeral-branch.outputs.is_hotfix }} + BYPASS_STANDARD_CHECKS: ${{ inputs.bypass_standard_checks }} run: | - echo "Release version bumps..." + set -euo pipefail # TODO: check if this is really necessary, we should have the full history from # previous steps. @@ -479,120 +484,18 @@ jobs: git fetch --unshallow fi - # Initialize results array. It holds one row per candidate crate: those released - # here, and those with no commits of their own, which carry "pending_release": - # "true" and are only released if the libdd-* major-bump check pulls them back in. - echo "[]" > /tmp/api-changes.json - - BRANCH_NAME="${{ steps.proposal-branch.outputs.branch_name }}" - - # iterate over the commits and execute cargo release for each crate - jq -c '.[]' /tmp/commits-by-crate.json | while read -r crate; do - NAME=$(echo "$crate" | jq -r '.name') - TAG=$(echo "$crate" | jq -r '.tag') - TAG_PREFIX="$NAME-v" - CRATE_PATH=$(echo "$crate" | jq -r '.path') - TAG_EXISTS=$(echo "$crate" | jq -r '.tag_exists') - COMMITS=$(echo "$crate" | jq -r '.commits') - INITIAL_RELEASE=false - - # if there are no commits and there is an existing tag, do not release the crate here. - # but record it as a pending candidate - if [ "$COMMITS" = "[]" ] && [ "$TAG_EXISTS" = "true" ]; then - VERSION=$(echo "$crate" | jq -r '.version') - echo "No commits since last release for $NAME; deferring to the libdd-* major-bump check" - jq --arg name "$NAME" \ - --arg tag "$TAG" \ - --arg version "$VERSION" \ - --arg path "$CRATE_PATH" \ - '. += [{"name": $name, "level": "none", "tag": $tag, "prev_tag": $tag, "version": $version, "range": "", "commits": [], "path": $path, "initial_release": "false", "pending_release": "true"}]' \ - /tmp/api-changes.json > /tmp/api-changes.tmp && mv /tmp/api-changes.tmp /tmp/api-changes.json - continue - fi - - if [ "$TAG_EXISTS" = "true" ]; then - TAG_COMMIT=$(echo "$crate" | jq -r '.tag_commit') - RANGE=$(echo "$crate" | jq -r '.range') - if [ -z "$TAG_COMMIT" ] || [ -z "$RANGE" ]; then - echo "ERROR: Could not dereference tag $TAG to a commit" >&2 - exit 1 - fi - echo "Using $RANGE as range (tag: $TAG)" - - if [ "$(echo "$crate" | jq -r '.tag_in_local_branch')" != "true" ]; then - echo "Warning: tag $TAG (commit $TAG_COMMIT) is not in any local branch (normal for squash-merged releases)" - fi + ARGS=() + if [ "$IS_HOTFIX" = "true" ]; then ARGS+=(--hotfix); fi + if [ "$BYPASS_STANDARD_CHECKS" = "true" ]; then ARGS+=(--bypass-standard-checks); fi - # if there is a tag more recent than $TAG, continue the loop. - LATEST_TAG=$(echo "$crate" | jq -r '.latest_tag') - if [ "$LATEST_TAG" != "$TAG" ]; then - echo "Tag $TAG is not the latest. Latest is: $LATEST_TAG. main branch has the latest release for $NAME" - - # do not skip the release for hotfix branches - if [ "${{ steps.ephemeral-branch.outputs.is_hotfix }}" = "true" ]; then - echo "Continuing with the release for $NAME because it is a hotfix" - else - if [ "${{ inputs.bypass_standard_checks }}" = "false" ]; then - echo "Skipping release for $NAME" - continue - else - echo "Continuing with the release for $NAME because bypass_standard_checks is true" - fi - fi - fi - - echo "Executing semver-level.sh for $NAME since $RANGE (tag: $TAG)..." - SEMVER_LEVEL=$("${WORKFLOW_SCRIPTS_ROOT}/semver-level.sh" "$NAME" "refs/tags/$TAG" 2>&1) - echo "Semver level: $SEMVER_LEVEL" - - LEVEL=$(echo "$SEMVER_LEVEL" | jq -r '.level') - - echo "Executing cargo release for $NAME since $TAG with level $LEVEL..." - cargo release version -p "$NAME" --prev-tag-name "$TAG" --allow-branch "$BRANCH_NAME" -x $LEVEL --no-confirm - - else - echo "No previous release tag for $NAME, preparing initial release..." - - # Use the version from the crate metadata - VERSION=$(echo "$crate" | jq -r '.version') - LEVEL="major" - TAG="" - RANGE="" - - # fail when the version is not an initial release - if [ "$VERSION" != "0.1.0" ]; then - echo "Error: $NAME is not a 0.1.0 release" >&2 - exit 1 - fi - - INITIAL_RELEASE=true - - echo "Executing cargo release for $NAME with level $LEVEL..." - cargo release version -p "$NAME" --allow-branch "$BRANCH_NAME" -x $LEVEL --no-confirm - fi - - # Commit the changes - cargo release commit --no-confirm -x - - NEXT_VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r --arg name "$NAME" '.packages[] | select(.name == $name) | .version') - NEXT_TAG="$TAG_PREFIX$NEXT_VERSION" - - # Add to results array - jq --arg name "$NAME" \ - --arg level "$LEVEL" \ - --arg tag "$NEXT_TAG" \ - --arg prev_tag "$TAG" \ - --arg version "$NEXT_VERSION" \ - --arg range "$RANGE" \ - --argjson commits "$COMMITS" \ - --arg path "$CRATE_PATH" \ - --arg initial_release "$INITIAL_RELEASE" \ - '. += [{"name": $name, "level": $level, "tag": $tag, "prev_tag": $prev_tag, "version": $version, "range": $range, "commits": $commits, "path": $path, "initial_release": $initial_release}]' \ - /tmp/api-changes.json > /tmp/api-changes.tmp && mv /tmp/api-changes.tmp /tmp/api-changes.json - done + "${WORKFLOW_SCRIPTS_ROOT}/release-version-bumps.sh" \ + --commits-by-crate /tmp/commits-by-crate.json \ + --out /tmp/api-changes.json \ + --branch "$PROPOSAL_BRANCH" \ + "${ARGS[@]}" # Check if there are commits to push or pending - if git diff --quiet "${{ steps.ephemeral-branch.outputs.ephemeral_branch }}"; then + if git diff --quiet "$EPHEMERAL_BRANCH"; then PENDING_COUNT=$(jq '[.[] | select(.pending_release == "true")] | length' /tmp/api-changes.json) if [ "$PENDING_COUNT" -gt 0 ]; then echo "No direct version bumps yet, but $PENDING_COUNT crate(s) are pending libdd-* major-bump evaluation; continuing." @@ -602,10 +505,6 @@ jobs: fi fi - # Output the results - echo "API changes summary:" - jq . /tmp/api-changes.json - - name: Update version for crates with libdd-* direct dependency major bumps since last release run: | set -euo pipefail diff --git a/scripts/release-version-bumps.sh b/scripts/release-version-bumps.sh new file mode 100755 index 0000000000..67f7bb3464 --- /dev/null +++ b/scripts/release-version-bumps.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +# Release Version Bumps Script +# Turns the output of commits-since-release.sh into api-changes.json, running +# `cargo release version` for each crate that is actually being released. +# +# Usage: ./release-version-bumps.sh --commits-by-crate FILE --out FILE --branch BRANCH +# [--hotfix] [--bypass-standard-checks] +# +# For every crate in the input, one of four things happens: +# +# deferred no commits of its own but a tag exists -- recorded with +# "pending_release": "true" and level "none", so the caller's libdd-* +# major-bump check can pull it back into the release, or drop it. +# skipped its tag is not the latest for that crate, so a newer release already +# exists elsewhere. Overridden by --hotfix and --bypass-standard-checks. +# released semver-level.sh picks the level, cargo-release applies it. +# initial no tag at all: released at 0.1.0, or the run fails. +# +# Diagnostics go to stdout (they are the caller's job log); the JSON result is written +# to --out. semver-level.sh is resolved next to this script, so it always comes from +# the same checkout as the caller. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +COMMITS_BY_CRATE="" +OUT_FILE="" +BRANCH_NAME="" +IS_HOTFIX=false +BYPASS_STANDARD_CHECKS=false + +usage() { + echo "Usage: $0 --commits-by-crate FILE --out FILE --branch BRANCH [--hotfix] [--bypass-standard-checks]" + echo "" + echo "Options:" + echo " --commits-by-crate FILE Output of commits-since-release.sh (required)" + echo " --out FILE Where to write the api-changes JSON array (required)" + echo " --branch BRANCH Branch cargo-release is allowed to operate on (required)" + echo " --hotfix Release even when the crate's tag is not the latest" + echo " --bypass-standard-checks Same, for testing runs" + echo " --help, -h Show this message" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --commits-by-crate) COMMITS_BY_CRATE="${2:?--commits-by-crate needs a value}"; shift 2 ;; + --out) OUT_FILE="${2:?--out needs a value}"; shift 2 ;; + --branch) BRANCH_NAME="${2:?--branch needs a value}"; shift 2 ;; + --hotfix) IS_HOTFIX=true; shift ;; + --bypass-standard-checks) BYPASS_STANDARD_CHECKS=true; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +[ -n "$COMMITS_BY_CRATE" ] || { echo "ERROR: --commits-by-crate is required" >&2; exit 1; } +[ -n "$OUT_FILE" ] || { echo "ERROR: --out is required" >&2; exit 1; } +[ -n "$BRANCH_NAME" ] || { echo "ERROR: --branch is required" >&2; exit 1; } +[ -f "$COMMITS_BY_CRATE" ] || { echo "ERROR: not a file: $COMMITS_BY_CRATE" >&2; exit 1; } +jq -e 'type == "array"' "$COMMITS_BY_CRATE" >/dev/null \ + || { echo "ERROR: $COMMITS_BY_CRATE is not a JSON array" >&2; exit 1; } + +echo "Release version bumps..." + +# Initialize results array. It holds one row per candidate crate: those released here, +# and those with no commits of their own, which carry "pending_release": "true" and are +# only released if the libdd-* major-bump check pulls them back in. +echo "[]" > "$OUT_FILE" + +append_row() { + # append_row JQ_ARGS... -- reads $OUT_FILE, appends, writes back. + local tmp="${OUT_FILE}.tmp" + jq "$@" "$OUT_FILE" > "$tmp" && mv "$tmp" "$OUT_FILE" +} + +# iterate over the commits and execute cargo release for each crate +while read -r crate; do + NAME=$(echo "$crate" | jq -r '.name') + TAG=$(echo "$crate" | jq -r '.tag') + TAG_PREFIX="$NAME-v" + CRATE_PATH=$(echo "$crate" | jq -r '.path') + TAG_EXISTS=$(echo "$crate" | jq -r '.tag_exists') + COMMITS=$(echo "$crate" | jq -r '.commits') + INITIAL_RELEASE=false + TAG_COMMIT="" + RANGE="" + LEVEL="" + + # if there are no commits and there is an existing tag, do not release the crate here. + # but record it as a pending candidate + if [ "$COMMITS" = "[]" ] && [ "$TAG_EXISTS" = "true" ]; then + VERSION=$(echo "$crate" | jq -r '.version') + echo "No commits since last release for $NAME; deferring to the libdd-* major-bump check" + append_row --arg name "$NAME" \ + --arg tag "$TAG" \ + --arg version "$VERSION" \ + --arg path "$CRATE_PATH" \ + '. += [{"name": $name, "level": "none", "tag": $tag, "prev_tag": $tag, "version": $version, "range": "", "commits": [], "path": $path, "initial_release": "false", "pending_release": "true"}]' + continue + fi + + if [ "$TAG_EXISTS" = "true" ]; then + TAG_COMMIT=$(echo "$crate" | jq -r '.tag_commit') + RANGE=$(echo "$crate" | jq -r '.range') + if [ -z "$TAG_COMMIT" ] || [ -z "$RANGE" ]; then + echo "ERROR: Could not dereference tag $TAG to a commit" >&2 + exit 1 + fi + echo "Using $RANGE as range (tag: $TAG)" + + if [ "$(echo "$crate" | jq -r '.tag_in_local_branch')" != "true" ]; then + echo "Warning: tag $TAG (commit $TAG_COMMIT) is not in any local branch (normal for squash-merged releases)" + fi + + # if there is a tag more recent than $TAG, continue the loop. + LATEST_TAG=$(echo "$crate" | jq -r '.latest_tag') + if [ "$LATEST_TAG" != "$TAG" ]; then + echo "Tag $TAG is not the latest. Latest is: $LATEST_TAG. main branch has the latest release for $NAME" + + # do not skip the release for hotfix branches + if [ "$IS_HOTFIX" = "true" ]; then + echo "Continuing with the release for $NAME because it is a hotfix" + else + if [ "$BYPASS_STANDARD_CHECKS" = "false" ]; then + echo "Skipping release for $NAME" + continue + else + echo "Continuing with the release for $NAME because bypass_standard_checks is true" + fi + fi + fi + + echo "Executing semver-level.sh for $NAME since $RANGE (tag: $TAG)..." + # stderr is folded in so the reason travels with a failure; without this the + # capture swallows it and the run aborts with nothing to go on. + if ! SEMVER_LEVEL=$("${SCRIPT_DIR}/semver-level.sh" "$NAME" "refs/tags/$TAG" 2>&1); then + echo "ERROR: semver-level.sh failed for $NAME:" >&2 + echo "$SEMVER_LEVEL" >&2 + exit 1 + fi + echo "Semver level: $SEMVER_LEVEL" + + LEVEL=$(echo "$SEMVER_LEVEL" | jq -r '.level') + + echo "Executing cargo release for $NAME since $TAG with level $LEVEL..." + cargo release version -p "$NAME" --prev-tag-name "$TAG" --allow-branch "$BRANCH_NAME" -x "$LEVEL" --no-confirm + + else + echo "No previous release tag for $NAME, preparing initial release..." + + # Use the version from the crate metadata + VERSION=$(echo "$crate" | jq -r '.version') + LEVEL="major" + TAG="" + RANGE="" + + # fail when the version is not an initial release + if [ "$VERSION" != "0.1.0" ]; then + echo "Error: $NAME is not a 0.1.0 release" >&2 + exit 1 + fi + + INITIAL_RELEASE=true + + echo "Executing cargo release for $NAME with level $LEVEL..." + cargo release version -p "$NAME" --allow-branch "$BRANCH_NAME" -x "$LEVEL" --no-confirm + fi + + # Commit the changes + cargo release commit --no-confirm -x + + NEXT_VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r --arg name "$NAME" '.packages[] | select(.name == $name) | .version') + NEXT_TAG="$TAG_PREFIX$NEXT_VERSION" + + # Add to results array + append_row --arg name "$NAME" \ + --arg level "$LEVEL" \ + --arg tag "$NEXT_TAG" \ + --arg prev_tag "$TAG" \ + --arg version "$NEXT_VERSION" \ + --arg range "$RANGE" \ + --argjson commits "$COMMITS" \ + --arg path "$CRATE_PATH" \ + --arg initial_release "$INITIAL_RELEASE" \ + '. += [{"name": $name, "level": $level, "tag": $tag, "prev_tag": $prev_tag, "version": $version, "range": $range, "commits": $commits, "path": $path, "initial_release": $initial_release}]' +done < <(jq -c '.[]' "$COMMITS_BY_CRATE") + +# Output the results +echo "API changes summary:" +jq . "$OUT_FILE" From eae9f929ab4a1b73c37f90f69803db543638a350 Mon Sep 17 00:00:00 2001 From: iunanua Date: Fri, 7 Aug 2026 09:32:45 +0200 Subject: [PATCH 08/14] refactor(release): pass BRANCH_NAME to the major-bumps step through env 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) --- .github/workflows/release-proposal-dispatch.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 81a7e8066f..d76dcf9f48 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -506,9 +506,10 @@ jobs: fi - name: Update version for crates with libdd-* direct dependency major bumps since last release + env: + BRANCH_NAME: ${{ steps.proposal-branch.outputs.branch_name }} run: | set -euo pipefail - BRANCH_NAME="${{ steps.proposal-branch.outputs.branch_name }}" # Audit input is api-changes.json: every candidate from the previous step -- the crates # released there and the pending no-commit candidates, which carry "pending_release": From 59fd7df832526c0af27a3ee0e6b6e498d7b4c7cd Mon Sep 17 00:00:00 2001 From: iunanua Date: Fri, 7 Aug 2026 09:44:15 +0200 Subject: [PATCH 09/14] refactor(release): move the major-bump audit into release-version-major-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) --- .../workflows/release-proposal-dispatch.yml | 90 +---------- scripts/release-version-major-bumps.sh | 144 ++++++++++++++++++ 2 files changed, 148 insertions(+), 86 deletions(-) create mode 100755 scripts/release-version-major-bumps.sh diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index d76dcf9f48..8c0ab0fd52 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -511,93 +511,11 @@ jobs: run: | set -euo pipefail - # Audit input is api-changes.json: every candidate from the previous step -- the crates - # released there and the pending no-commit candidates, which carry "pending_release": - # "true" so we can tell them apart below. Every row is checked the same way for direct - # libdd-* major bumps; only what happens to the result differs. - # - # Run the audit in a throwaway worktree so extra worktrees / cargo metadata do not touch - # the job checkout. Check it out at the proposal branch tip (HEAD) — the released ref plus - # this run's version bumps from the previous step. This is deliberate on both ends: - # - It includes the dependency-requirement rewrites cargo-release made in the previous - # step, so a dependency bumped to a new major IN THIS proposal propagates a major bump - # to its dependents (e.g. protobuf 3->4 forces its dependents major). - # - It is built from the released ref, NOT github.sha, so changes present only on current - # main (and absent from a hotfix/older-ref release) never trigger a spurious bump. - MAJOR_BUMPS_WT=$(mktemp -d "${RUNNER_TEMP:-/tmp}/major-bumps-wt.XXXXXX") - PROPOSAL_SHA=$(git rev-parse HEAD) - - git worktree add --detach "$MAJOR_BUMPS_WT" "$PROPOSAL_SHA" - set +e - ( cd "$MAJOR_BUMPS_WT" && "${WORKFLOW_SCRIPTS_ROOT}/major-bumps-level.sh" /tmp/api-changes.json ) \ - > /tmp/api-changes-with-major-bumps-pre-commit.json - MB_RC=$? - git worktree remove --force "$MAJOR_BUMPS_WT" || true - set -e - if [[ "$MB_RC" -ne 0 ]]; then - echo "Major bumps level script failed with code $MB_RC" - echo "Major bumps level script output:" - cat /tmp/api-changes-with-major-bumps-pre-commit.json - exit "$MB_RC" - fi - - # Seed the result with every already-released crate. Pending crates are appended below - # only if they earn a major bump; those that do not stay out of the release entirely. - jq '[.[] | select(.pending_release != "true") | del(.pending_release)]' \ - /tmp/api-changes-with-major-bumps-pre-commit.json > /tmp/api-changes-with-major-bumps.json - - # iterate over the crates and, where a direct libdd-* dependency had a major bump, update the version - jq -c '.[]' /tmp/api-changes-with-major-bumps-pre-commit.json | while read -r bump; do - NAME=$(echo "$bump" | jq -r '.name') - LEVEL=$(echo "$bump" | jq -r '.level') - PREV_TAG=$(echo "$bump" | jq -r '.prev_tag') - TAG=$(echo "$bump" | jq -r '.tag') - VERSION=$(echo "$bump" | jq -r '.version') - PENDING=$(echo "$bump" | jq -r '.pending_release // "false"') - MAJOR_BUMPS=$(echo "$bump" | jq -c '.major_bumps') - - if [ "$MAJOR_BUMPS" = "[]" ]; then - if [ "$PENDING" = "true" ]; then - echo "No commits and no direct dependency major bumps for $NAME, keeping it out of the release" - fi - continue - fi + "${WORKFLOW_SCRIPTS_ROOT}/release-version-major-bumps.sh" \ + --api-changes /tmp/api-changes.json \ + --out /tmp/api-changes-with-major-bumps.json \ + --branch "$BRANCH_NAME" - # A crate already bumped to major in the previous step needs nothing more. Pending - # crates always have level "none" here, so this only short-circuits released crates. - if [ "$LEVEL" = "major" ]; then - echo "Skipping $NAME: already bumped at major level in the previous step (major_bumps: $MAJOR_BUMPS)" - continue - fi - - # Bump to major: either a pending (no-commit) crate whose direct dependency went major, - # or a released crate bumped below major in the previous step. Both are handled the same. - echo "Bumping $NAME to major due to direct dependency major bumps: $MAJOR_BUMPS" - cargo release version -p "$NAME" --prev-tag-name "$PREV_TAG" --allow-branch "$BRANCH_NAME" -x major --no-confirm - - git commit -am "chore(release): update version for $NAME with major bumps" - - NEXT_VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r --arg name "$NAME" '.packages[] | select(.name == $name) | .version') - NEXT_TAG="$NAME-v$NEXT_VERSION" - - echo "Updating tag $TAG to $NEXT_TAG and version $VERSION to $NEXT_VERSION for $NAME" - - # Released crates are already in the result (seeded above): update them in place. Pending - # crates are not: append them. The row is derived from the audit entry either way. - ROW=$(echo "$bump" | jq --arg version "$NEXT_VERSION" --arg tag "$NEXT_TAG" \ - 'del(.pending_release) | . + {level: "major", version: $version, tag: $tag}') - jq --argjson row "$ROW" \ - 'if any(.[]; .name == $row.name) - then map(if .name == $row.name then $row else . end) - else . + [$row] end' \ - /tmp/api-changes-with-major-bumps.json > /tmp/api-changes-with-major-bumps.tmp \ - && mv /tmp/api-changes-with-major-bumps.tmp /tmp/api-changes-with-major-bumps.json - done - - # Output the results - echo "API changes with major bumps summary:" - jq . /tmp/api-changes-with-major-bumps.json - - name: Generate CHANGELOGS id: generate-changelogs run: | diff --git a/scripts/release-version-major-bumps.sh b/scripts/release-version-major-bumps.sh new file mode 100755 index 0000000000..11748060bf --- /dev/null +++ b/scripts/release-version-major-bumps.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +# Release Version Major Bumps Script +# Audits every release candidate for direct libdd-* dependencies that went to a new +# major version, and promotes the crates that need it. +# +# Usage: ./release-version-major-bumps.sh --api-changes FILE --out FILE --branch BRANCH +# +# Input is the api-changes array produced by release-version-bumps.sh: the crates +# released there, plus the no-commit candidates carrying "pending_release": "true". +# Every row is audited the same way; only what happens to the result differs. +# +# released, no bump kept as it is +# released, already major kept as it is; nothing more to do +# released, below major promoted to major, version and tag updated +# pending, earns a bump promoted to major and pulled into the release +# pending, earns nothing dropped from the release entirely +# +# Diagnostics go to stdout (they are the caller's job log); the JSON result is written +# to --out. major-bumps-level.sh is resolved next to this script, so it always comes +# from the same checkout as the caller. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +API_CHANGES="" +OUT_FILE="" +BRANCH_NAME="" + +usage() { + echo "Usage: $0 --api-changes FILE --out FILE --branch BRANCH" + echo "" + echo "Options:" + echo " --api-changes FILE Output of release-version-bumps.sh (required)" + echo " --out FILE Where to write the audited JSON array (required)" + echo " --branch BRANCH Branch cargo-release is allowed to operate on (required)" + echo " --help, -h Show this message" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --api-changes) API_CHANGES="${2:?--api-changes needs a value}"; shift 2 ;; + --out) OUT_FILE="${2:?--out needs a value}"; shift 2 ;; + --branch) BRANCH_NAME="${2:?--branch needs a value}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +[ -n "$API_CHANGES" ] || { echo "ERROR: --api-changes is required" >&2; exit 1; } +[ -n "$OUT_FILE" ] || { echo "ERROR: --out is required" >&2; exit 1; } +[ -n "$BRANCH_NAME" ] || { echo "ERROR: --branch is required" >&2; exit 1; } +[ -f "$API_CHANGES" ] || { echo "ERROR: not a file: $API_CHANGES" >&2; exit 1; } +jq -e 'type == "array"' "$API_CHANGES" >/dev/null \ + || { echo "ERROR: $API_CHANGES is not a JSON array" >&2; exit 1; } + +AUDITED=$(mktemp "${TMPDIR:-/tmp}/api-changes-with-major-bumps-pre-commit.XXXXXX.json") +cleanup() { rm -f "$AUDITED"; } +trap cleanup EXIT + +# Run the audit in a throwaway worktree so extra worktrees / cargo metadata do not touch +# the caller's checkout. Check it out at the proposal branch tip (HEAD) — the released ref +# plus this run's version bumps from the previous step. This is deliberate on both ends: +# - It includes the dependency-requirement rewrites cargo-release made in the previous +# step, so a dependency bumped to a new major IN THIS proposal propagates a major bump +# to its dependents (e.g. protobuf 3->4 forces its dependents major). +# - It is built from the released ref, NOT the workflow revision, so changes present only +# on current main (and absent from a hotfix/older-ref release) never trigger a +# spurious bump. +MAJOR_BUMPS_WT=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/major-bumps-wt.XXXXXX") +PROPOSAL_SHA=$(git rev-parse HEAD) + +git worktree add --detach "$MAJOR_BUMPS_WT" "$PROPOSAL_SHA" +set +e +( cd "$MAJOR_BUMPS_WT" && "${SCRIPT_DIR}/major-bumps-level.sh" "$API_CHANGES" ) > "$AUDITED" +MB_RC=$? +git worktree remove --force "$MAJOR_BUMPS_WT" || true +set -e +if [[ "$MB_RC" -ne 0 ]]; then + echo "Major bumps level script failed with code $MB_RC" + echo "Major bumps level script output:" + cat "$AUDITED" + exit "$MB_RC" +fi + +# Seed the result with every already-released crate. Pending crates are appended below +# only if they earn a major bump; those that do not stay out of the release entirely. +jq '[.[] | select(.pending_release != "true") | del(.pending_release)]' "$AUDITED" > "$OUT_FILE" + +# iterate over the crates and, where a direct libdd-* dependency had a major bump, update the version +while read -r bump; do + NAME=$(echo "$bump" | jq -r '.name') + LEVEL=$(echo "$bump" | jq -r '.level') + PREV_TAG=$(echo "$bump" | jq -r '.prev_tag') + TAG=$(echo "$bump" | jq -r '.tag') + VERSION=$(echo "$bump" | jq -r '.version') + PENDING=$(echo "$bump" | jq -r '.pending_release // "false"') + MAJOR_BUMPS=$(echo "$bump" | jq -c '.major_bumps') + + if [ "$MAJOR_BUMPS" = "[]" ]; then + if [ "$PENDING" = "true" ]; then + echo "No commits and no direct dependency major bumps for $NAME, keeping it out of the release" + fi + continue + fi + + # A crate already bumped to major in the previous step needs nothing more. Pending + # crates always have level "none" here, so this only short-circuits released crates. + if [ "$LEVEL" = "major" ]; then + echo "Skipping $NAME: already bumped at major level in the previous step (major_bumps: $MAJOR_BUMPS)" + continue + fi + + # Bump to major: either a pending (no-commit) crate whose direct dependency went major, + # or a released crate bumped below major in the previous step. Both are handled the same. + echo "Bumping $NAME to major due to direct dependency major bumps: $MAJOR_BUMPS" + cargo release version -p "$NAME" --prev-tag-name "$PREV_TAG" --allow-branch "$BRANCH_NAME" -x major --no-confirm + + git commit -am "chore(release): update version for $NAME with major bumps" + + NEXT_VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r --arg name "$NAME" '.packages[] | select(.name == $name) | .version') + NEXT_TAG="$NAME-v$NEXT_VERSION" + + echo "Updating tag $TAG to $NEXT_TAG and version $VERSION to $NEXT_VERSION for $NAME" + + # Released crates are already in the result (seeded above): update them in place. Pending + # crates are not: append them. The row is derived from the audit entry either way. + ROW=$(echo "$bump" | jq --arg version "$NEXT_VERSION" --arg tag "$NEXT_TAG" \ + 'del(.pending_release) | . + {level: "major", version: $version, tag: $tag}') + jq --argjson row "$ROW" \ + 'if any(.[]; .name == $row.name) + then map(if .name == $row.name then $row else . end) + else . + [$row] end' \ + "$OUT_FILE" > "${OUT_FILE}.tmp" \ + && mv "${OUT_FILE}.tmp" "$OUT_FILE" +done < <(jq -c '.[]' "$AUDITED") + +# Output the results +echo "API changes with major bumps summary:" +jq . "$OUT_FILE" From 0c7fd782ad8e724f88e8166d366189d20be46d47 Mon Sep 17 00:00:00 2001 From: iunanua Date: Fri, 7 Aug 2026 10:09:08 +0200 Subject: [PATCH 10/14] refactor(release): pass the ephemeral branch to Generate CHANGELOGS through 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) --- .github/workflows/release-proposal-dispatch.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 8c0ab0fd52..32a0204aa2 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -507,17 +507,19 @@ jobs: - name: Update version for crates with libdd-* direct dependency major bumps since last release env: - BRANCH_NAME: ${{ steps.proposal-branch.outputs.branch_name }} + PROPOSAL_BRANCH: ${{ steps.proposal-branch.outputs.branch_name }} run: | set -euo pipefail "${WORKFLOW_SCRIPTS_ROOT}/release-version-major-bumps.sh" \ --api-changes /tmp/api-changes.json \ --out /tmp/api-changes-with-major-bumps.json \ - --branch "$BRANCH_NAME" + --branch "$PROPOSAL_BRANCH" - name: Generate CHANGELOGS id: generate-changelogs + env: + EPHEMERAL_BRANCH: ${{ steps.ephemeral-branch.outputs.ephemeral_branch }} run: | set -euo pipefail ORIGINAL_HEAD=$(cat /tmp/release_head_sha) @@ -636,7 +638,7 @@ jobs: done # Check if there are commits to push - if git diff --quiet "${{ steps.ephemeral-branch.outputs.ephemeral_branch }}"; then + if git diff --quiet "$EPHEMERAL_BRANCH"; then echo "No changes to push. Cancelling the workflow." exit 1 fi From 47c025155bbc5c068bafb8510277fa793c8c8e05 Mon Sep 17 00:00:00 2001 From: iunanua Date: Fri, 7 Aug 2026 10:21:04 +0200 Subject: [PATCH 11/14] refactor(release): move CHANGELOG generation into release-generate-changelogs.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) --- .../workflows/release-proposal-dispatch.yml | 114 +----------- scripts/release-generate-changelogs.sh | 164 ++++++++++++++++++ 2 files changed, 166 insertions(+), 112 deletions(-) create mode 100755 scripts/release-generate-changelogs.sh diff --git a/.github/workflows/release-proposal-dispatch.yml b/.github/workflows/release-proposal-dispatch.yml index 32a0204aa2..9f01582b35 100644 --- a/.github/workflows/release-proposal-dispatch.yml +++ b/.github/workflows/release-proposal-dispatch.yml @@ -524,118 +524,8 @@ jobs: set -euo pipefail ORIGINAL_HEAD=$(cat /tmp/release_head_sha) - echo "Generating CHANGELOGS" - - jq -c '.[]' /tmp/api-changes-with-major-bumps.json | while read -r bump; do - COMMITS=$(echo "$bump" | jq -r '.commits') - RANGE=$(echo "$bump" | jq -r '.range') - NAME=$(echo "$bump" | jq -r '.name') - TAG=$(echo "$bump" | jq -r '.prev_tag') - NEXT_TAG=$(echo "$bump" | jq -r '.tag') - VERSION=$(echo "$bump" | jq -r '.version') - CRATE_PATH=$(echo "$bump" | jq -r '.path') - INITIAL_RELEASE=$(echo "$bump" | jq -r '.initial_release') - MAJOR_BUMPS=$(echo "$bump" | jq -c '.major_bumps // []') - - if [ "$INITIAL_RELEASE" = "true" ]; then - echo "Initial release for $NAME" - - # Use the existing CHANGELOG.md if present, otherwise create a minimal one - if [ ! -f "$CRATE_PATH/CHANGELOG.md" ]; then - echo "Creating CHANGELOG.md for $NAME..." - RELEASE_DATE=$(date +%Y-%m-%d) - printf '# Changelog\n\n\n## %s - %s\n\nInitial release.\n' "$VERSION" "$RELEASE_DATE" > "$CRATE_PATH/CHANGELOG.md" - - git add "$CRATE_PATH/CHANGELOG.md" - git commit -m "chore(release): update CHANGELOG.md for $NAME" - else - echo "Using existing CHANGELOG.md for $NAME..." - fi - continue - fi - - # FIXME: $COMMITS could be empty if there are no commits since last release - if [ "$COMMITS" = "[]" ]; then - if [ "$MAJOR_BUMPS" != "[]" ] && [ "$MAJOR_BUMPS" != "null" ]; then - echo "No commits for $NAME but direct dependency major bumps; writing a minimal CHANGELOG entry" - RELEASE_DATE=$(date +%Y-%m-%d) - DEP_LINES=$(echo "$MAJOR_BUMPS" | jq -r '.[] | "- Bump `\(.dependency)` to a new major version (`\(.previous_req)` → `\(.current_req)`)"') - - # Match git-cliff's header (see cliff.toml): link the version to a compare view - # against the previous tag when one exists. - REMOTE_URL="https://github.com/datadog/libdatadog" - if [ -n "$TAG" ] && [ "$TAG" != "null" ]; then - HEADER="## [$VERSION]($REMOTE_URL/compare/$TAG..$NEXT_TAG) - $RELEASE_DATE" - else - HEADER="## [$VERSION] - $RELEASE_DATE" - fi - - ENTRY_FILE=$(mktemp /tmp/changelog-entry-XXXXXX.md) - printf '%s\n\n### Changed\n\n%s\n\n' "$HEADER" "$DEP_LINES" > "$ENTRY_FILE" - - if [ -f "$CRATE_PATH/CHANGELOG.md" ]; then - # Insert the new section above the first existing release section (newest-first), - # mirroring git-cliff --prepend placement and leaving the rest of the file intact. - awk 'NR==FNR { e = e $0 ORS; next } - !inserted && /^## / { printf "%s", e; inserted=1 } - { print } - END { if (!inserted) printf "%s", e }' \ - "$ENTRY_FILE" "$CRATE_PATH/CHANGELOG.md" > "$CRATE_PATH/CHANGELOG.md.tmp" - mv "$CRATE_PATH/CHANGELOG.md.tmp" "$CRATE_PATH/CHANGELOG.md" - else - printf '# Changelog\n\n\n' > "$CRATE_PATH/CHANGELOG.md" - cat "$ENTRY_FILE" >> "$CRATE_PATH/CHANGELOG.md" - fi - rm -f "$ENTRY_FILE" - - git add "$CRATE_PATH/CHANGELOG.md" - git commit -m "chore(release): update CHANGELOG.md for $NAME" - else - echo "No commits since last release for $NAME, skipping CHANGELOG generation" - fi - continue - fi - - # Build a tight range from commits already found by commits-since-release.sh. - # This will save some time analising unnecessary commits and prevent unrelated commits - # go through git-cliff filtering process. - NEWEST_COMMIT=$(echo "$COMMITS" | jq -r '.[0].hash // empty') - OLDEST_COMMIT=$(echo "$COMMITS" | jq -r '.[-1].hash // empty') - # --verify matters: plain `git rev-parse ^` exits non-zero but still - # echoes "^" on stdout, so the `|| echo ""` fallback to $RANGE would never - # fire and git-cliff would be handed a range start that does not resolve. - OLDEST_PARENT=$(git rev-parse --verify "${OLDEST_COMMIT}^" 2>/dev/null || echo "") - if [ -n "$OLDEST_PARENT" ] && [ -n "$NEWEST_COMMIT" ]; then - COMMITS_RANGE="$OLDEST_PARENT..$NEWEST_COMMIT" - else - COMMITS_RANGE="$RANGE" - fi - echo "Executing git cliff for $NAME since $COMMITS_RANGE (oldest: $OLDEST_COMMIT, newest: $NEWEST_COMMIT), next tag: $NEXT_TAG..." - - # git-cliff's --include-path uses cumulative tree diffs rather than per-commit - # diffs. This causes commits that don't touch the crate to pass the filter if an - # earlier commit in the range does touch it. In order to avoid that a first pass - # will generate the context inside the commit range and then a second step will - # filter the the commits according to the previously computed range stored in COMMITS. - CLIFF_CONTEXT_FILE=$(mktemp /tmp/git-cliff-context-XXXXXX.json) - CLIFF_HASHES_FILE=$(mktemp /tmp/git-cliff-hashes-XXXXXX.json) - CLIFF_FILTERED_FILE=$(mktemp /tmp/git-cliff-filtered-XXXXXX.json) - - git cliff --context --tag "$NEXT_TAG" --ignore-tags ".*" -v "$COMMITS_RANGE" > "$CLIFF_CONTEXT_FILE" - echo "$COMMITS" | jq '[.[].hash]' > "$CLIFF_HASHES_FILE" - jq --slurpfile hashes "$CLIFF_HASHES_FILE" \ - --arg prev_tag "$TAG" \ - 'map(. + { - commits: [.commits[] | select(.id | IN($hashes[0][]))], - previous: (.previous + {"version": $prev_tag}) - })' \ - "$CLIFF_CONTEXT_FILE" > "$CLIFF_FILTERED_FILE" - git cliff --from-context "$CLIFF_FILTERED_FILE" -u -v --prepend "$CRATE_PATH/CHANGELOG.md" - rm -f "$CLIFF_CONTEXT_FILE" "$CLIFF_HASHES_FILE" "$CLIFF_FILTERED_FILE" - - git add "$CRATE_PATH/CHANGELOG.md" - git commit -m "chore(release): update CHANGELOG.md for $NAME" - done + "${WORKFLOW_SCRIPTS_ROOT}/release-generate-changelogs.sh" \ + --api-changes /tmp/api-changes-with-major-bumps.json # Check if there are commits to push if git diff --quiet "$EPHEMERAL_BRANCH"; then diff --git a/scripts/release-generate-changelogs.sh b/scripts/release-generate-changelogs.sh new file mode 100755 index 0000000000..f263c8d901 --- /dev/null +++ b/scripts/release-generate-changelogs.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +# Release Generate CHANGELOGs Script +# Writes and commits a CHANGELOG.md entry for every crate in the release set. +# +# Usage: ./release-generate-changelogs.sh --api-changes FILE +# +# Input is the audited release set from release-version-major-bumps.sh. Each crate +# takes one of four paths: +# +# initial release an existing CHANGELOG.md is left alone; otherwise a minimal +# "Initial release." file is created. +# no commits, but a direct libdd-* dependency went major: a minimal entry listing +# the dependency bumps, formatted to match git-cliff's header so +# the file stays consistent. +# no commits at all no entry; nothing to say. +# commits git-cliff, in two passes (see below). +# +# Every entry that is written is committed as "chore(release): update CHANGELOG.md +# for ". Runs from the repository root, where cliff.toml lives. + +set -euo pipefail + +API_CHANGES="" +REMOTE_URL="https://github.com/datadog/libdatadog" + +usage() { + echo "Usage: $0 --api-changes FILE" + echo "" + echo "Options:" + echo " --api-changes FILE Audited release set from release-version-major-bumps.sh (required)" + echo " --remote-url URL Repository URL used in generated compare links" + echo " (default: $REMOTE_URL)" + echo " --help, -h Show this message" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --api-changes) API_CHANGES="${2:?--api-changes needs a value}"; shift 2 ;; + --remote-url) REMOTE_URL="${2:?--remote-url needs a value}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +[ -n "$API_CHANGES" ] || { echo "ERROR: --api-changes is required" >&2; exit 1; } +[ -f "$API_CHANGES" ] || { echo "ERROR: not a file: $API_CHANGES" >&2; exit 1; } +jq -e 'type == "array"' "$API_CHANGES" >/dev/null \ + || { echo "ERROR: $API_CHANGES is not a JSON array" >&2; exit 1; } + +echo "Generating CHANGELOGS" + +while read -r bump; do + COMMITS=$(echo "$bump" | jq -r '.commits') + RANGE=$(echo "$bump" | jq -r '.range') + NAME=$(echo "$bump" | jq -r '.name') + TAG=$(echo "$bump" | jq -r '.prev_tag') + NEXT_TAG=$(echo "$bump" | jq -r '.tag') + VERSION=$(echo "$bump" | jq -r '.version') + CRATE_PATH=$(echo "$bump" | jq -r '.path') + INITIAL_RELEASE=$(echo "$bump" | jq -r '.initial_release') + MAJOR_BUMPS=$(echo "$bump" | jq -c '.major_bumps // []') + + if [ "$INITIAL_RELEASE" = "true" ]; then + echo "Initial release for $NAME" + + # Use the existing CHANGELOG.md if present, otherwise create a minimal one + if [ ! -f "$CRATE_PATH/CHANGELOG.md" ]; then + echo "Creating CHANGELOG.md for $NAME..." + RELEASE_DATE=$(date +%Y-%m-%d) + printf '# Changelog\n\n\n## %s - %s\n\nInitial release.\n' "$VERSION" "$RELEASE_DATE" > "$CRATE_PATH/CHANGELOG.md" + + git add "$CRATE_PATH/CHANGELOG.md" + git commit -m "chore(release): update CHANGELOG.md for $NAME" + else + echo "Using existing CHANGELOG.md for $NAME..." + fi + continue + fi + + # FIXME: $COMMITS could be empty if there are no commits since last release + if [ "$COMMITS" = "[]" ]; then + if [ "$MAJOR_BUMPS" != "[]" ] && [ "$MAJOR_BUMPS" != "null" ]; then + echo "No commits for $NAME but direct dependency major bumps; writing a minimal CHANGELOG entry" + RELEASE_DATE=$(date +%Y-%m-%d) + DEP_LINES=$(echo "$MAJOR_BUMPS" | jq -r '.[] | "- Bump `\(.dependency)` to a new major version (`\(.previous_req)` → `\(.current_req)`)"') + + # Match git-cliff's header (see cliff.toml): link the version to a compare view + # against the previous tag when one exists. + if [ -n "$TAG" ] && [ "$TAG" != "null" ]; then + HEADER="## [$VERSION]($REMOTE_URL/compare/$TAG..$NEXT_TAG) - $RELEASE_DATE" + else + HEADER="## [$VERSION] - $RELEASE_DATE" + fi + + ENTRY_FILE=$(mktemp "${TMPDIR:-/tmp}/changelog-entry-XXXXXX.md") + printf '%s\n\n### Changed\n\n%s\n\n' "$HEADER" "$DEP_LINES" > "$ENTRY_FILE" + + if [ -f "$CRATE_PATH/CHANGELOG.md" ]; then + # Insert the new section above the first existing release section (newest-first), + # mirroring git-cliff --prepend placement and leaving the rest of the file intact. + awk 'NR==FNR { e = e $0 ORS; next } + !inserted && /^## / { printf "%s", e; inserted=1 } + { print } + END { if (!inserted) printf "%s", e }' \ + "$ENTRY_FILE" "$CRATE_PATH/CHANGELOG.md" > "$CRATE_PATH/CHANGELOG.md.tmp" + mv "$CRATE_PATH/CHANGELOG.md.tmp" "$CRATE_PATH/CHANGELOG.md" + else + printf '# Changelog\n\n\n' > "$CRATE_PATH/CHANGELOG.md" + cat "$ENTRY_FILE" >> "$CRATE_PATH/CHANGELOG.md" + fi + rm -f "$ENTRY_FILE" + + git add "$CRATE_PATH/CHANGELOG.md" + git commit -m "chore(release): update CHANGELOG.md for $NAME" + else + echo "No commits since last release for $NAME, skipping CHANGELOG generation" + fi + continue + fi + + # Build a tight range from commits already found by commits-since-release.sh. + # This will save some time analising unnecessary commits and prevent unrelated commits + # go through git-cliff filtering process. + NEWEST_COMMIT=$(echo "$COMMITS" | jq -r '.[0].hash // empty') + OLDEST_COMMIT=$(echo "$COMMITS" | jq -r '.[-1].hash // empty') + # --verify matters: plain `git rev-parse ^` exits non-zero but still + # echoes "^" on stdout, so the `|| echo ""` fallback to $RANGE would never + # fire and git-cliff would be handed a range start that does not resolve. + OLDEST_PARENT=$(git rev-parse --verify "${OLDEST_COMMIT}^" 2>/dev/null || echo "") + if [ -n "$OLDEST_PARENT" ] && [ -n "$NEWEST_COMMIT" ]; then + COMMITS_RANGE="$OLDEST_PARENT..$NEWEST_COMMIT" + else + COMMITS_RANGE="$RANGE" + fi + echo "Executing git cliff for $NAME since $COMMITS_RANGE (oldest: $OLDEST_COMMIT, newest: $NEWEST_COMMIT), next tag: $NEXT_TAG..." + + # git-cliff's --include-path uses cumulative tree diffs rather than per-commit + # diffs. This causes commits that don't touch the crate to pass the filter if an + # earlier commit in the range does touch it. In order to avoid that a first pass + # will generate the context inside the commit range and then a second step will + # filter the the commits according to the previously computed range stored in COMMITS. + CLIFF_CONTEXT_FILE=$(mktemp "${TMPDIR:-/tmp}/git-cliff-context-XXXXXX.json") + CLIFF_HASHES_FILE=$(mktemp "${TMPDIR:-/tmp}/git-cliff-hashes-XXXXXX.json") + CLIFF_FILTERED_FILE=$(mktemp "${TMPDIR:-/tmp}/git-cliff-filtered-XXXXXX.json") + + git cliff --context --tag "$NEXT_TAG" --ignore-tags ".*" -v "$COMMITS_RANGE" > "$CLIFF_CONTEXT_FILE" + echo "$COMMITS" | jq '[.[].hash]' > "$CLIFF_HASHES_FILE" + jq --slurpfile hashes "$CLIFF_HASHES_FILE" \ + --arg prev_tag "$TAG" \ + 'map(. + { + commits: [.commits[] | select(.id | IN($hashes[0][]))], + previous: (.previous + {"version": $prev_tag}) + })' \ + "$CLIFF_CONTEXT_FILE" > "$CLIFF_FILTERED_FILE" + git cliff --from-context "$CLIFF_FILTERED_FILE" -u -v --prepend "$CRATE_PATH/CHANGELOG.md" + rm -f "$CLIFF_CONTEXT_FILE" "$CLIFF_HASHES_FILE" "$CLIFF_FILTERED_FILE" + + git add "$CRATE_PATH/CHANGELOG.md" + git commit -m "chore(release): update CHANGELOG.md for $NAME" +done < <(jq -c '.[]' "$API_CHANGES") From 510544a3ed1cb5865443392c3f52547e9aa8cadd Mon Sep 17 00:00:00 2001 From: iunanua Date: Fri, 7 Aug 2026 10:34:16 +0200 Subject: [PATCH 12/14] chore(codeowners): add the extracted release scripts 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) --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0a1fa57c04..a5a1f7d26e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -87,6 +87,9 @@ scripts/exclude-from-green-ci.sh @DataDog/libdatadog-core scripts/major-bumps-level.sh @DataDog/libdatadog-core scripts/publication-order.sh @DataDog/libdatadog-core scripts/reformat_copyright.sh @DataDog/libdatadog-core +scripts/release-generate-changelogs.sh @DataDog/libdatadog-core +scripts/release-version-bumps.sh @DataDog/libdatadog-core +scripts/release-version-major-bumps.sh @DataDog/libdatadog-core scripts/semver-level.sh @DataDog/libdatadog-core scripts/update_license_3rdparty.sh @DataDog/libdatadog-core scripts/Dockerfile.license @DataDog/libdatadog-core From 4ee9001ed9611c5e4a5ebce2394089a2d5a00e29 Mon Sep 17 00:00:00 2001 From: iunanua Date: Mon, 10 Aug 2026 18:03:36 +0200 Subject: [PATCH 13/14] fix(release): propagate a failing jq into the release script loops The three extracted loops read their rows through `done < <(jq -c '.[]' ...)`. A process substitution's exit status is reported by nothing: set -e does not see it, and pipefail does not apply because there is no pipeline. A jq that fails mid-stream leaves the loop with no input and the script exits 0 having done nothing. Reproduced with a jq that exits 5 on the streaming call: both release-version-bumps.sh and release-generate-changelogs.sh exited 0. This was introduced here, when the steps were lifted out of the workflow and the original pipes became process substitutions. Worse, the commit that moved the version-bumps loop justified itself partly on `set -euo pipefail` closing this exact hole, which it does not. The changelog case is the dangerous one. The caller's guard after the step is `git diff --quiet "$EPHEMERAL_BRANCH"`, which still sees the version-bump commits from the previous step, so a run that generated no CHANGELOG at all looks like a run with something to release, and the proposal goes out without changelogs. Materialize the rows into a variable first: a command substitution's status is checked by set -e. A here-string over an empty variable still feeds one blank line, so the loops skip empty rows. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/release-generate-changelogs.sh | 12 +++++++++++- scripts/release-version-bumps.sh | 10 +++++++++- scripts/release-version-major-bumps.sh | 10 +++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/release-generate-changelogs.sh b/scripts/release-generate-changelogs.sh index f263c8d901..a3d0b1f681 100755 --- a/scripts/release-generate-changelogs.sh +++ b/scripts/release-generate-changelogs.sh @@ -53,7 +53,17 @@ jq -e 'type == "array"' "$API_CHANGES" >/dev/null \ echo "Generating CHANGELOGS" +# Materialize the rows before looping. `done < <(jq ...)` would run jq in a process +# substitution, whose exit status no shell option reports: set -e and pipefail both +# ignore it, so a jq that dies mid-stream would leave the loop with no input and this +# script would exit 0 having written no CHANGELOG at all. The caller cannot catch that +# either: its no-changes-to-push guard sees the version-bump commits from the previous +# step and concludes there is something to release. +RELEASE_ROWS=$(jq -c '.[]' "$API_CHANGES") + while read -r bump; do + # $ROWS is empty when there are no candidates; <<< still feeds one blank line. + [ -n "$bump" ] || continue COMMITS=$(echo "$bump" | jq -r '.commits') RANGE=$(echo "$bump" | jq -r '.range') NAME=$(echo "$bump" | jq -r '.name') @@ -161,4 +171,4 @@ while read -r bump; do git add "$CRATE_PATH/CHANGELOG.md" git commit -m "chore(release): update CHANGELOG.md for $NAME" -done < <(jq -c '.[]' "$API_CHANGES") +done <<< "$RELEASE_ROWS" diff --git a/scripts/release-version-bumps.sh b/scripts/release-version-bumps.sh index 67f7bb3464..9851f1f111 100755 --- a/scripts/release-version-bumps.sh +++ b/scripts/release-version-bumps.sh @@ -78,8 +78,16 @@ append_row() { jq "$@" "$OUT_FILE" > "$tmp" && mv "$tmp" "$OUT_FILE" } +# Materialize the rows before looping. `done < <(jq ...)` would run jq in a process +# substitution, whose exit status no shell option reports: set -e and pipefail both +# ignore it, so a jq that dies mid-stream would leave the loop with no input and this +# script would exit 0 having released nothing. +CRATE_ROWS=$(jq -c '.[]' "$COMMITS_BY_CRATE") + # iterate over the commits and execute cargo release for each crate while read -r crate; do + # $ROWS is empty when there are no candidates; <<< still feeds one blank line. + [ -n "$crate" ] || continue NAME=$(echo "$crate" | jq -r '.name') TAG=$(echo "$crate" | jq -r '.tag') TAG_PREFIX="$NAME-v" @@ -188,7 +196,7 @@ while read -r crate; do --arg path "$CRATE_PATH" \ --arg initial_release "$INITIAL_RELEASE" \ '. += [{"name": $name, "level": $level, "tag": $tag, "prev_tag": $prev_tag, "version": $version, "range": $range, "commits": $commits, "path": $path, "initial_release": $initial_release}]' -done < <(jq -c '.[]' "$COMMITS_BY_CRATE") +done <<< "$CRATE_ROWS" # Output the results echo "API changes summary:" diff --git a/scripts/release-version-major-bumps.sh b/scripts/release-version-major-bumps.sh index 11748060bf..d09f150ddb 100755 --- a/scripts/release-version-major-bumps.sh +++ b/scripts/release-version-major-bumps.sh @@ -91,8 +91,16 @@ fi # only if they earn a major bump; those that do not stay out of the release entirely. jq '[.[] | select(.pending_release != "true") | del(.pending_release)]' "$AUDITED" > "$OUT_FILE" +# Materialize the rows before looping. `done < <(jq ...)` would run jq in a process +# substitution, whose exit status no shell option reports: set -e and pipefail both +# ignore it, so a jq that dies mid-stream would leave the loop with no input and this +# script would exit 0 having promoted nothing. +AUDIT_ROWS=$(jq -c '.[]' "$AUDITED") + # iterate over the crates and, where a direct libdd-* dependency had a major bump, update the version while read -r bump; do + # $ROWS is empty when there are no candidates; <<< still feeds one blank line. + [ -n "$bump" ] || continue NAME=$(echo "$bump" | jq -r '.name') LEVEL=$(echo "$bump" | jq -r '.level') PREV_TAG=$(echo "$bump" | jq -r '.prev_tag') @@ -137,7 +145,7 @@ while read -r bump; do else . + [$row] end' \ "$OUT_FILE" > "${OUT_FILE}.tmp" \ && mv "${OUT_FILE}.tmp" "$OUT_FILE" -done < <(jq -c '.[]' "$AUDITED") +done <<< "$AUDIT_ROWS" # Output the results echo "API changes with major bumps summary:" From d8546337c2daccd7003d3e3d5ef63d861ebfa9a0 Mon Sep 17 00:00:00 2001 From: iunanua Date: Mon, 10 Aug 2026 18:16:05 +0200 Subject: [PATCH 14/14] fix(release): resolve --api-changes before the major-bump audit changes directory The path is validated in the caller's directory and then passed unchanged into `( cd "$MAJOR_BUMPS_WT" && major-bumps-level.sh "$API_CHANGES" )`, so a relative --api-changes is looked up from the throwaway worktree and fails there with "Not a file" after validating fine a few lines earlier. The workflow passes an absolute /tmp path, so this never fired in the release job, but the documented interface takes a generic FILE and the script is meant to be runnable by hand. Resolve it to an absolute path right after the -f check, whose success already guarantees the dirname exists. --api-changes is the only path that crosses the cd: $AUDITED comes from mktemp, $SCRIPT_DIR is absolute, and $OUT_FILE is only ever written from the caller's directory. Relative --out keeps working. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/release-version-major-bumps.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/release-version-major-bumps.sh b/scripts/release-version-major-bumps.sh index d09f150ddb..2c1a77d59a 100755 --- a/scripts/release-version-major-bumps.sh +++ b/scripts/release-version-major-bumps.sh @@ -58,6 +58,11 @@ done jq -e 'type == "array"' "$API_CHANGES" >/dev/null \ || { echo "ERROR: $API_CHANGES is not a JSON array" >&2; exit 1; } +# Resolve to an absolute path while we are still in the caller's directory. The audit +# below runs in `cd "$MAJOR_BUMPS_WT"`, so a relative --api-changes would be looked up +# from the throwaway worktree and fail there, having validated fine here. +API_CHANGES="$(cd -- "$(dirname -- "$API_CHANGES")" && pwd)/$(basename -- "$API_CHANGES")" + AUDITED=$(mktemp "${TMPDIR:-/tmp}/api-changes-with-major-bumps-pre-commit.XXXXXX.json") cleanup() { rm -f "$AUDITED"; } trap cleanup EXIT