Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
390 changes: 35 additions & 355 deletions .github/workflows/release-proposal-dispatch.yml

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions scripts/check_cargo_metadata.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 83 additions & 6 deletions scripts/commits-since-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,22 @@ ${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":"<sha>",'
echo ' "tag_in_local_branch":true,"latest_tag":"crate-name-v1.0.0",'
echo ' "range":"<start-sha>..<head-sha>","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'
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'
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
;;
-*)
Expand Down Expand Up @@ -99,6 +114,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
Expand Down Expand Up @@ -153,19 +172,35 @@ 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:-<none>}"

# Check if tag exists
TAG_EXISTS=false
TAG_ANCESTOR="unknown"
TAG_COMMIT=""
TAG_IN_LOCAL_BRANCH=false
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"
Expand All @@ -174,20 +209,34 @@ 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


# 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="["
Expand Down Expand Up @@ -216,9 +265,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 <root-commit>^` exits non-zero but
# still echoes "<sha>^" 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
Expand All @@ -230,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\",\"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 '.[]')

Expand Down
10 changes: 0 additions & 10 deletions scripts/major-bumps-level.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
174 changes: 174 additions & 0 deletions scripts/release-generate-changelogs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/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 <crate>". 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"

# 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')
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 <root-commit>^` exits non-zero but still
# echoes "<sha>^" 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 <<< "$RELEASE_ROWS"
Loading
Loading