diff --git a/.github/chainguard/self.approve-trivial.approve-pr.sts.yaml b/.github/chainguard/self.approve-trivial.approve-pr.sts.yaml index eb52fc892e..81566d5916 100644 --- a/.github/chainguard/self.approve-trivial.approve-pr.sts.yaml +++ b/.github/chainguard/self.approve-trivial.approve-pr.sts.yaml @@ -4,11 +4,10 @@ subject: repo:DataDog/java-profiler:pull_request claim_pattern: event_name: pull_request_target - ref: refs/heads/main + ref: refs/heads/(main|release/[0-9]+\.[0-9]+\._) ref_protected: "true" - job_workflow_ref: DataDog/java-profiler/\.github/workflows/approve-trivial\.yml@refs/heads/main + job_workflow_ref: DataDog/java-profiler/\.github/workflows/approve-trivial\.yml@refs/heads/(main|release/[0-9]+\.[0-9]+\._) permissions: contents: read pull_requests: write - diff --git a/.github/chainguard/self.release-bump.create-pr.sts.yaml b/.github/chainguard/self.release-bump.create-pr.sts.yaml deleted file mode 100644 index cf3a4b456f..0000000000 --- a/.github/chainguard/self.release-bump.create-pr.sts.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Allows release-validated.yml's create-release job to open and label the -# automated post-release version-bump PR with an identity distinct from the -# default GITHUB_TOKEN. -# -# This matters because GitHub does not fire new workflow runs for events -# caused by the default GITHUB_TOKEN. If the bump PR were labeled using -# GITHUB_TOKEN, approve-trivial.yml's `pull_request_target: types: [labeled]` -# trigger would never fire, the PR would never get its required approval, -# and `gh pr merge --auto` would stall forever on main (1 approval required). -issuer: https://token.actions.githubusercontent.com - -# release-validated.yml is workflow_dispatch'd against either main -# (major/minor) or a release/X.Y._ branch (patch/retag). -subject_pattern: "repo:DataDog/java-profiler:ref:refs/heads/(main|release/[0-9]+\\.[0-9]+\\._)" - -claim_pattern: - event_name: workflow_dispatch - job_workflow_ref: DataDog/java-profiler/\.github/workflows/release-validated\.yml@.* - -permissions: - contents: read - pull_requests: write diff --git a/.github/chainguard/self.release-bump.label-pr.sts.yaml b/.github/chainguard/self.release-bump.label-pr.sts.yaml new file mode 100644 index 0000000000..dd1b0aaef4 --- /dev/null +++ b/.github/chainguard/self.release-bump.label-pr.sts.yaml @@ -0,0 +1,11 @@ +issuer: https://token.actions.githubusercontent.com + +subject_pattern: 'repo:DataDog/java-profiler:ref:refs/heads/(main|release/[0-9]+\.[0-9]+\._)' + +claim_pattern: + event_name: workflow_dispatch + job_workflow_ref: DataDog/java-profiler/\.github/workflows/release-validated\.yml@refs/heads/(main|release/[0-9]+\.[0-9]+\._) + +permissions: + contents: read + pull_requests: write diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 02ff9d9636..049bc5849e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,7 @@ updates: labels: - "dependencies" - "no-release-notes" - - "no-review" + - "trivial" ignore: # JUnit 5.10+ dropped Java 8 support; 5.12+ dropped Java 11; 6.x requires Java 17. # CI targets on Java 8 and 11 (HotSpot and J9) run the Gradle test worker on the @@ -40,7 +40,7 @@ updates: labels: - "dependencies" - "no-release-notes" - - "no-review" + - "trivial" groups: gradle-minor: update-types: @@ -59,7 +59,7 @@ updates: labels: - "dependencies" - "no-release-notes" - - "no-review" + - "trivial" groups: # init and analyze must stay on the same codeql-action release, or # analyze fails with "Loaded a configuration file for version X, but diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 3b031b65a4..a6d876f6cd 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -1,13 +1,22 @@ #!/usr/bin/env bash -set -x -set -e +set -euo pipefail TYPE=$1 -DRYRUN=$2 +DRYRUN=${2:-} + +git_push() { + if [ -n "$DRYRUN" ]; then + git push "$DRYRUN" "$@" + else + git push "$@" + fi +} BRANCH=$(git branch --show-current) +SOURCE_SHA=$(git rev-parse HEAD) RELEASE_BRANCH= +FIRST_PATCH=false BASE=$(./gradlew printVersion -Psnapshot=false | grep 'Version:' | cut -f2 -d' ') # BASE == 0.0.1 @@ -47,7 +56,9 @@ create_annotated_tag() { local branch=$3 local tag_name="v_${version}" - local tag_message="Release v_${version} (${type,,}) from ${branch}" + local lowercase_type + lowercase_type=$(tr '[:upper:]' '[:lower:]' <<<"$type") + local tag_message="Release v_${version} (${lowercase_type}) from ${branch}" # Check if tag exists if git rev-parse "$tag_name" >/dev/null 2>&1; then @@ -82,7 +93,9 @@ if [ "$TYPE" == "MINOR" ] || [ "$TYPE" == "MAJOR" ]; then fi RELEASE_BRANCH="release/${BASE%.*}._" check_not_stuck "$BASE" "$BRANCH" - create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + if [ "$TYPE" == "MINOR" ]; then + create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + fi fi if [ "$TYPE" == "PATCH" ]; then @@ -91,8 +104,27 @@ if [ "$TYPE" == "PATCH" ]; then exit 1 fi RELEASE_BRANCH="release/${BASE%.*}._" - check_not_stuck "$BASE" "$BRANCH" - create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + IFS=. read -r BASE_MAJOR BASE_MINOR BASE_PATCH <<<"$BASE" + if git show-ref --verify --quiet "refs/tags/v_${BASE}" && [ "$BASE_PATCH" -eq 0 ]; then + if [ "$(git cat-file -t "refs/tags/v_${BASE}" 2>/dev/null)" != "tag" ]; then + echo "::error::First patch base v_${BASE} must be an annotated tag" + exit 1 + fi + if ! git merge-base --is-ancestor "v_${BASE}^{commit}" "$SOURCE_SHA"; then + echo "::error::First patch base tag v_${BASE} is not reachable from $SOURCE_SHA" + exit 1 + fi + FIRST_PATCH=true + ./gradlew incrementVersion --versionIncrementType=PATCH + BASE=$(./gradlew printVersion -Psnapshot=false | grep 'Version:' | cut -f2 -d' ') + [ "$BASE" = "$BASE_MAJOR.$BASE_MINOR.$((BASE_PATCH + 1))" ] || { + echo "::error::First patch increment produced unexpected version $BASE" + exit 1 + } + else + check_not_stuck "$BASE" "$BRANCH" + create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + fi fi # RETAG: re-point an existing tag at the current HEAD of a release branch. @@ -134,20 +166,41 @@ if [ "$TYPE" == "RETAG" ]; then echo "Retagged Version: $BASE" echo "Tag: $TAG_NAME -> $(git rev-parse HEAD)" echo "========================================================" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "base_branch=$BRANCH" + echo "source_sha=$SOURCE_SHA" + echo "release_version=$BASE" + echo "release_branch=$BRANCH" + } >> "$GITHUB_OUTPUT" + fi exit 0 fi if [ "$BRANCH" != "$RELEASE_BRANCH" ]; then - git checkout -b $RELEASE_BRANCH + git checkout -b "$RELEASE_BRANCH" if ! git diff --quiet; then git add build.gradle.kts git commit -m "[Automated] Release ${BASE}" fi - git push $DRYRUN --atomic --set-upstream origin $RELEASE_BRANCH - git checkout $BRANCH + if [ "$TYPE" == "MAJOR" ]; then + create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + fi + git_push --atomic --set-upstream origin "$RELEASE_BRANCH" + git checkout "$BRANCH" +elif [ "$FIRST_PATCH" == "true" ]; then + git add build.gradle.kts + git commit -m "[Automated] Release ${BASE}" + create_annotated_tag "$BASE" "$TYPE" "$BRANCH" + git_push --atomic origin "$BRANCH" fi -if [ "$TYPE" == "MAJOR" ] || [ "$TYPE" == "MINOR" ]; then +if [ "$TYPE" == "MAJOR" ]; then + # The release commit stays on release/X.0._. Main moves directly from the + # previous development version to X.1.0 through the validated bump PR. + ./gradlew incrementVersion --versionIncrementType=MAJOR + ./gradlew incrementVersion --versionIncrementType=MINOR +elif [ "$TYPE" == "MINOR" ]; then ./gradlew incrementVersion --versionIncrementType=MINOR else ./gradlew incrementVersion --versionIncrementType=PATCH @@ -155,31 +208,106 @@ fi CANDIDATE=$(./gradlew printVersion -Psnapshot=false | grep 'Version:' | cut -f2 -d' ') +FINAL_BUMP_MESSAGE="[Automated] Bump dev version to ${CANDIDATE}" git add build.gradle.kts -git commit -m "[Automated] Bump dev version to ${CANDIDATE}" +# GITHUB_TOKEN-created pull_request.opened events do not start other workflows. +# Open the PR from this temporary commit, then amend and push the canonical +# commit through SSH so GitHub emits a normal pull_request.synchronize event. +git commit -m "[Automated] Prepare dev version bump to ${CANDIDATE}" if [ -z "$DRYRUN" ]; then + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + : "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" + : "${BUMP_LABEL_TOKEN:?BUMP_LABEL_TOKEN is required}" BUMP_BRANCH="automated/bump-${CANDIDATE//./-}" git checkout -b "$BUMP_BRANCH" git push --force-with-lease --set-upstream origin "$BUMP_BRANCH" - # Create and label with the federated BUMP_PR_TOKEN: GitHub does not fire new - # workflow runs for labeled events caused by the default GITHUB_TOKEN, so the - # no-review label would never trigger approve-trivial.yml. Enable auto-merge - # with the write-enabled GITHUB_TOKEN after emitting the labeled event. - BUMP_PR_URL=$(GH_TOKEN="$BUMP_PR_TOKEN" gh pr create \ - --title "[Automated] Bump dev version to ${CANDIDATE}" \ - --body "Automated version bump after releasing v_${BASE}." \ - --base "$BRANCH" \ - --head "$BUMP_BRANCH") - GH_TOKEN="$BUMP_PR_TOKEN" gh pr edit "$BUMP_PR_URL" --add-label "no-review" - GH_TOKEN="$GITHUB_TOKEN" gh pr merge "$BUMP_PR_URL" --auto --squash - echo "BUMP_PR_URL=$BUMP_PR_URL" >> "${GITHUB_OUTPUT:-/dev/null}" - echo "✓ Version bump PR created and queued for auto-merge: $BUMP_PR_URL" + INITIAL_BUMP_SHA=$(git rev-parse HEAD) + + if ! BUMP_PR_JSON=$(GH_TOKEN="$GITHUB_TOKEN" gh api --method POST \ + "repos/$GITHUB_REPOSITORY/pulls" \ + -f title="$FINAL_BUMP_MESSAGE" \ + -f head="$BUMP_BRANCH" \ + -f base="$BRANCH" \ + -f body="Automated post-release development version bump for v_$BASE."); then + echo "::error::Unable to create the release bump PR with GITHUB_TOKEN." + echo "::error::Verify that Actions is allowed to create and approve pull requests in repository settings." + exit 1 + fi + BUMP_PR_NUMBER=$(jq -er '.number' <<<"$BUMP_PR_JSON") + BUMP_PR_URL=$(jq -er '.html_url' <<<"$BUMP_PR_JSON") + + git commit --amend -m "$FINAL_BUMP_MESSAGE" + BUMP_HEAD_SHA=$(git rev-parse HEAD) + [ "$BUMP_HEAD_SHA" != "$INITIAL_BUMP_SHA" ] || { + echo "::error::Amending the bump commit did not change its SHA" + exit 1 + } + git push \ + --force-with-lease="refs/heads/$BUMP_BRANCH:$INITIAL_BUMP_SHA" \ + origin "$BUMP_BRANCH:$BUMP_BRANCH" + + # Validate every remotely visible commit before publishing the release tag, + # which triggers artifact publication. Major preflight additionally proves + # that the still-local annotated tag identifies the remote release commit. + if [ "$TYPE" == "MAJOR" ]; then + ./.github/scripts/validate-release-bump.sh \ + --repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \ + --branch \ + --base "$BRANCH" \ + --head "$BUMP_BRANCH" \ + --source-sha "$SOURCE_SHA" \ + --expected-head-sha "$BUMP_HEAD_SHA" \ + --local-release-tag "v_$BASE" + elif [ "$FIRST_PATCH" == "true" ]; then + ./.github/scripts/validate-release-bump.sh \ + --repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \ + --branch \ + --first-patch \ + --base "$BRANCH" \ + --head "$BUMP_BRANCH" \ + --source-sha "$SOURCE_SHA" \ + --expected-head-sha "$BUMP_HEAD_SHA" \ + --local-release-tag "v_$BASE" + else + ./.github/scripts/validate-release-bump.sh \ + --repo "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" \ + --branch \ + --base "$BRANCH" \ + --head "$BUMP_BRANCH" \ + --source-sha "$SOURCE_SHA" \ + --expected-head-sha "$BUMP_HEAD_SHA" + fi + git push --atomic --tags + + # The STS identity is deliberately distinct from github-actions[bot], which + # created the PR. Its label event starts the trusted approval workflow. + GH_TOKEN="$BUMP_LABEL_TOKEN" gh api --method POST \ + "repos/$GITHUB_REPOSITORY/issues/$BUMP_PR_NUMBER/labels" \ + -f 'labels[]=trivial' >/dev/null + echo "✓ Version bump PR opened and labeled for gated merge: $BUMP_PR_URL" else - git push $DRYRUN --atomic --set-upstream origin $BRANCH + BUMP_BRANCH="automated/bump-${CANDIDATE//./-}" + BUMP_HEAD_SHA=$(git rev-parse HEAD) + BUMP_PR_NUMBER= + BUMP_PR_URL= + git_push --atomic --set-upstream origin "$BRANCH" + git_push --atomic --tags fi -git push $DRYRUN --atomic --tags +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "base_branch=$BRANCH" + echo "source_sha=$SOURCE_SHA" + echo "release_version=$BASE" + echo "next_version=$CANDIDATE" + echo "release_branch=$RELEASE_BRANCH" + echo "bump_branch=$BUMP_BRANCH" + echo "bump_head_sha=$BUMP_HEAD_SHA" + echo "bump_pr_number=$BUMP_PR_NUMBER" + echo "bump_pr_url=$BUMP_PR_URL" + } >> "$GITHUB_OUTPUT" +fi echo "==================== RELEASE SUMMARY ====================" echo "Release Type: $TYPE" @@ -188,6 +316,6 @@ echo "Next Dev Version: $CANDIDATE" echo "Release Branch: $RELEASE_BRANCH" echo "Tag: v_$BASE" if [ -z "$DRYRUN" ]; then - echo "Tag Message: $(git tag -l v_$BASE -n1 --format='%(contents:subject)')" + echo "Tag Message: $(git tag -l "v_$BASE" -n1 --format='%(contents:subject)')" fi echo "==========================================================" diff --git a/.github/scripts/tests/test_release_automation.sh b/.github/scripts/tests/test_release_automation.sh new file mode 100755 index 0000000000..9a73d0a59f --- /dev/null +++ b/.github/scripts/tests/test_release_automation.sh @@ -0,0 +1,898 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +VALIDATOR="$ROOT/.github/scripts/validate-release-bump.sh" +TRIVIAL_VALIDATOR="$ROOT/.github/scripts/validate-trivial-approval.sh" +WAITER="$ROOT/.github/scripts/wait-release-bump.sh" +TEMP_DIR=$(mktemp -d) +TESTS=0 + +# This suite must never mutate GitHub or any other network remote. Restrict Git +# to filesystem transports and shadow every network-capable command used by the +# release automation. Individual integration scenarios prepend their own gh +# fixture, while every unexpected network command fails immediately. +export GIT_ALLOW_PROTOCOL=file +NETWORK_GUARD_BIN="$TEMP_DIR/network-guard-bin" +mkdir "$NETWORK_GUARD_BIN" +for command in gh ssh scp curl wget; do + printf '%s\n' \ + '#!/usr/bin/env bash' \ + "echo \"network command blocked by hermetic release test: \${0##*/}\" >&2" \ + 'exit 97' > "$NETWORK_GUARD_BIN/$command" + chmod +x "$NETWORK_GUARD_BIN/$command" +done +PATH="$NETWORK_GUARD_BIN:$PATH" +export PATH + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +pass() { + TESTS=$((TESTS + 1)) +} + +write_valid_fixture() { + local path=$1 + local parent='plugins { + java +} + +version = "1.48.0-SNAPSHOT" +' + local head='plugins { + java +} + +version = "1.49.0-SNAPSHOT" +' + jq -n \ + --arg repo "DataDog/java-profiler" \ + --arg sender_login "dd-octo-sts[bot]" \ + --arg sender_type "Bot" \ + --arg sender_permission "none" \ + --arg author_login "github-actions[bot]" \ + --arg author_type "Bot" \ + --arg state "open" \ + --arg title "[Automated] Bump dev version to 1.49.0" \ + --arg base_ref "main" \ + --arg base_sha "$(printf 'c%.0s' {1..40})" \ + --arg head_ref "automated/bump-1-49-0" \ + --arg head_sha "$(printf 'b%.0s' {1..40})" \ + --arg head_repo "DataDog/java-profiler" \ + --arg parent_sha "$(printf 'a%.0s' {1..40})" \ + --arg parent_build "$parent" \ + --arg head_build "$head" \ + '{ + repo: $repo, + sender_login: $sender_login, + sender_type: $sender_type, + sender_permission: $sender_permission, + author_login: $author_login, + author_type: $author_type, + state: $state, + draft: false, + title: $title, + base_ref: $base_ref, + base_sha: $base_sha, + head_ref: $head_ref, + head_sha: $head_sha, + current_head_sha: $head_sha, + head_repo: $head_repo, + labels: ["trivial"], + changed_files: [{filename: "build.gradle.kts", status: "modified"}], + head_parents: [$parent_sha], + parent_sha: $parent_sha, + parent_reachable: true, + parent_build: $parent_build, + head_build: $head_build + }' > "$path" +} + +expect_success() { + local fixture=$1 + shift + "$VALIDATOR" --fixture "$fixture" "$@" >/dev/null || + fail "expected validation success for $fixture" + pass +} + +expect_failure() { + local fixture=$1 + shift + if "$VALIDATOR" --fixture "$fixture" "$@" >/dev/null 2>&1; then + fail "expected validation failure for $fixture" + fi + pass +} + +write_labeler_fixture() { + local path=$1 + local actor=$2 + local permission=$3 + local actor_type=$4 + jq -n --arg actor "$actor" --arg permission "$permission" \ + --arg actor_type "$actor_type" \ + '{actor: $actor, permission: $permission, actor_type: $actor_type}' > "$path" +} + +expect_trivial_success() { + local actor=$1 + local permission=$2 + local fixture="$TEMP_DIR/trivial-$actor.json" + local actor_type=${3:-User} + write_labeler_fixture "$fixture" "$actor" "$permission" "$actor_type" + "$TRIVIAL_VALIDATOR" --actor "$actor" --actor-type "$actor_type" \ + --fixture "$fixture" >/dev/null || + fail "expected trivial approval authorization for $actor/$permission" + pass +} + +expect_trivial_failure() { + local actor=$1 + local permission=$2 + local fixture="$TEMP_DIR/trivial-$actor.json" + local actor_type=${3:-User} + write_labeler_fixture "$fixture" "$actor" "$permission" "$actor_type" + if "$TRIVIAL_VALIDATOR" --actor "$actor" --actor-type "$actor_type" \ + --fixture "$fixture" >/dev/null 2>&1; then + fail "expected trivial approval rejection for $actor/$permission" + fi + pass +} + +expect_trivial_success release-engineer write +expect_trivial_success release-maintainer maintain +expect_trivial_success repository-admin admin +expect_trivial_success 'dependabot[bot]' read Bot +expect_trivial_success 'dd-octo-sts[bot]' read Bot +expect_trivial_failure contributor triage +expect_trivial_failure unknown-user read +expect_trivial_failure 'unknown[bot]' admin Bot + +if PERMISSION_ERROR=$("$TRIVIAL_VALIDATOR" \ + --actor unavailable-user --actor-type User 2>&1); then + fail "trivial validator accepted an actor after permission lookup failure" +fi +[ "$PERMISSION_ERROR" = \ + "trivial approval validation failed: unable to determine repository permission for label actor unavailable-user" ] || + fail "trivial validator did not normalize permission lookup failure" +pass + +VALID="$TEMP_DIR/valid.json" +write_valid_fixture "$VALID" +expect_success "$VALID" +expect_success "$VALID" \ + --source-sha "$(printf 'a%.0s' {1..40})" \ + --expected-head-sha "$(printf 'b%.0s' {1..40})" + +HUMAN_LABELER="$TEMP_DIR/human-labeler.json" +jq '.sender_login = "release-engineer" | .sender_type = "User" | .sender_permission = "maintain"' \ + "$VALID" > "$HUMAN_LABELER" +expect_success "$HUMAN_LABELER" + +PATCH="$TEMP_DIR/patch.json" +jq ' + .base_ref = "release/1.48._" | + .head_ref = "automated/bump-1-48-1" | + .title = "[Automated] Bump dev version to 1.48.1" | + .head_build |= sub("1.49.0"; "1.48.1") +' "$VALID" > "$PATCH" +expect_success "$PATCH" + +FIRST_PATCH="$TEMP_DIR/first-patch.json" +jq \ + --arg source_sha "$(printf 'a%.0s' {1..40})" \ + --arg release_sha "$(printf 'd%.0s' {1..40})" \ + ' + .base_ref = "release/1.48._" | + .base_sha = $release_sha | + .head_ref = "automated/bump-1-48-2" | + .title = "[Automated] Bump dev version to 1.48.2" | + .parent_sha = $release_sha | + .head_parents = [$release_sha] | + .parent_build |= sub("1.48.0"; "1.48.1") | + .head_build |= sub("1.49.0"; "1.48.2") | + .source_sha = $source_sha | + .source_build = (.parent_build | sub("1.48.1"; "1.48.0")) | + .release_tag_sha = $release_sha | + .release_tag_annotated = true | + .release_parents = [$source_sha] | + .release_changed_files = [{filename: "build.gradle.kts", status: "modified"}] | + .source_tag_annotated = true | + .source_tag_reachable = true + ' "$VALID" > "$FIRST_PATCH" +expect_success "$FIRST_PATCH" --branch --first-patch \ + --source-sha "$(printf 'a%.0s' {1..40})" \ + --expected-head-sha "$(printf 'b%.0s' {1..40})" \ + --local-release-tag v_1.48.1 + +declare -a FIRST_PATCH_MUTATIONS=( + '.release_tag_annotated = false' + '.release_tag_sha = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"' + '.release_parents = ["eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]' + '.release_changed_files += [{filename: "payload", status: "added"}]' + '.source_tag_annotated = false' + '.source_tag_reachable = false' + '.source_build |= sub("1.48.0"; "1.48.1")' + '.parent_build += "malicious = true\n"' +) +for index in "${!FIRST_PATCH_MUTATIONS[@]}"; do + fixture="$TEMP_DIR/invalid-first-patch-$index.json" + jq "${FIRST_PATCH_MUTATIONS[$index]}" "$FIRST_PATCH" > "$fixture" + expect_failure "$fixture" --branch --first-patch \ + --source-sha "$(printf 'a%.0s' {1..40})" \ + --expected-head-sha "$(printf 'b%.0s' {1..40})" \ + --local-release-tag v_1.48.1 +done + +ROLLOVER="$TEMP_DIR/rollover.json" +jq ' + .parent_build |= sub("1.48.0"; "1.99.0") | + .head_build |= sub("1.49.0"; "2.0.0") | + .head_ref = "automated/bump-2-0-0" | + .title = "[Automated] Bump dev version to 2.0.0" +' "$VALID" > "$ROLLOVER" +expect_success "$ROLLOVER" + +STS_BOT="$TEMP_DIR/sts-bot.json" +jq ' + .author_login = "dd-octo-sts[bot]" | + .author_type = "Bot" +' "$VALID" > "$STS_BOT" +expect_failure "$STS_BOT" + +HUMAN_AUTHOR="$TEMP_DIR/human-author.json" +jq '.author_login = "release-engineer" | .author_type = "User"' \ + "$VALID" > "$HUMAN_AUTHOR" +expect_failure "$HUMAN_AUTHOR" + +MAJOR="$TEMP_DIR/major.json" +jq \ + --arg release_sha "$(printf 'd%.0s' {1..40})" \ + ' + .head_ref = "automated/bump-2-1-0" | + .title = "[Automated] Bump dev version to 2.1.0" | + .head_build |= sub("1.49.0"; "2.1.0") | + .release_ref_sha = $release_sha | + .release_tag_sha = $release_sha | + .release_tag_annotated = true | + .release_parents = [.parent_sha] | + .release_changed_files = [{filename: "build.gradle.kts", status: "modified"}] | + .release_build = (.parent_build | sub("1.48.0"; "2.0.0")) + ' "$VALID" > "$MAJOR" +expect_success "$MAJOR" + +declare -a MAJOR_MUTATIONS=( + '.release_tag_annotated = false' + '.release_tag_sha = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"' + '.release_parents = ["eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]' + '.release_changed_files += [{filename: "payload", status: "added"}]' + '.release_build |= sub("2.0.0"; "2.0.1")' + '.release_build += "malicious = true\n"' + '.head_build |= sub("2.1.0"; "3.1.0")' +) +for index in "${!MAJOR_MUTATIONS[@]}"; do + fixture="$TEMP_DIR/invalid-major-$index.json" + jq "${MAJOR_MUTATIONS[$index]}" "$MAJOR" > "$fixture" + expect_failure "$fixture" +done + +BRANCH="$TEMP_DIR/branch.json" +jq '.expected_head_sha = .head_sha | .source_sha = .parent_sha' "$VALID" > "$BRANCH" +expect_success "$BRANCH" --branch \ + --source-sha "$(printf 'a%.0s' {1..40})" \ + --expected-head-sha "$(printf 'b%.0s' {1..40})" + +declare -a MUTATIONS=( + '.sender_login = "unknown[bot]"' + '.sender_type = "User" | .sender_permission = "read"' + '.author_login = "unknown[bot]"' + '.author_type = "User"' + '.draft = true' + '.state = "closed"' + '.base_ref = "feature"' + '.head_ref = "attacker/bump-1-49-0"' + '.head_repo = "attacker/java-profiler"' + '.labels = []' + '.changed_files += [{filename: "payload", status: "added"}]' + '.head_parents += ["dddddddddddddddddddddddddddddddddddddddd"]' + '.parent_reachable = false' + '.current_head_sha = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"' + '.title = "Looks harmless"' + '.head_build |= sub("1.49.0"; "1.50.0")' + '.head_build += "malicious = true\n"' + '.head_build |= rtrimstr("\n")' +) + +for index in "${!MUTATIONS[@]}"; do + fixture="$TEMP_DIR/invalid-$index.json" + jq "${MUTATIONS[$index]}" "$VALID" > "$fixture" + expect_failure "$fixture" +done + +WRONG_PARENT="$TEMP_DIR/wrong-parent.json" +jq '.parent_sha = "ffffffffffffffffffffffffffffffffffffffff"' \ + "$VALID" > "$WRONG_PARENT" +expect_failure "$WRONG_PARENT" + +WRONG_HEAD="$TEMP_DIR/wrong-head.json" +jq '.expected_head_sha = "ffffffffffffffffffffffffffffffffffffffff"' \ + "$VALID" > "$WRONG_HEAD" +expect_failure "$WRONG_HEAD" --branch \ + --source-sha "$(printf 'a%.0s' {1..40})" \ + --expected-head-sha "$(printf 'b%.0s' {1..40})" + +WRONG_SERIES="$TEMP_DIR/wrong-series.json" +jq ' + .base_ref = "release/1.47._" | + .head_ref = "automated/bump-1-48-1" | + .title = "[Automated] Bump dev version to 1.48.1" | + .head_build |= sub("1.49.0"; "1.48.1") +' "$VALID" > "$WRONG_SERIES" +expect_failure "$WRONG_SERIES" + +NETWORK_DIR="$TEMP_DIR/no-network" +mkdir "$NETWORK_DIR" +NETWORK_MARKER="$NETWORK_DIR/gh-was-called" +printf '#!/usr/bin/env bash\n: > "%s"\nexit 99\n' "$NETWORK_MARKER" \ + > "$NETWORK_DIR/gh" +chmod +x "$NETWORK_DIR/gh" +PATH="$NETWORK_DIR:/usr/bin:/bin" "$VALIDATOR" --fixture "$VALID" >/dev/null || + fail "fixture validation failed with a fail-closed gh stub" +[ ! -e "$NETWORK_MARKER" ] || fail "fixture validation invoked gh" +pass + +RELEASE_SCRIPT=$(<"$ROOT/.github/scripts/release.sh") +VALIDATOR_SCRIPT=$(<"$ROOT/.github/scripts/validate-release-bump.sh") +TRIVIAL_VALIDATOR_SCRIPT=$(<"$TRIVIAL_VALIDATOR") +[[ "$TRIVIAL_VALIDATOR_SCRIPT" == *'dependabot\[bot\]|dd-octo-sts\[bot\]'* ]] || + fail "trivial validator does not use the trusted bot allowlist" +[[ "$TRIVIAL_VALIDATOR_SCRIPT" == *"write|maintain|admin"* ]] || + fail "trivial validator does not accept trusted human permissions" +pass +[[ "$VALIDATOR_SCRIPT" == *'LABEL="trivial"'* ]] || + fail "validator does not use the trivial label" +[[ "$RELEASE_SCRIPT" != *"set -x"* ]] || + fail "release script enables credential-bearing shell tracing" +[[ "$VALIDATOR_SCRIPT" == *'github-actions[bot]'* ]] || + fail "validator does not require the GitHub Actions PR author" +[[ "$VALIDATOR_SCRIPT" == *'dd-octo-sts[bot]'* ]] || + fail "validator does not require the distinct STS labeler" +[[ "$RELEASE_SCRIPT" == *"GH_TOKEN=\"\$GITHUB_TOKEN\" gh api --method POST"* ]] || + fail "release job does not create the PR with GITHUB_TOKEN" +[[ "$RELEASE_SCRIPT" == *"GH_TOKEN=\"\$BUMP_LABEL_TOKEN\" gh api --method POST"* ]] || + fail "release job does not label the PR with the distinct STS token" +[[ "$RELEASE_SCRIPT" != *"gh pr merge"* ]] || + fail "release script can merge before approval and selected CI pass" +[[ "$RELEASE_SCRIPT" == *"git commit --amend"* ]] || + fail "release job does not produce the CI-triggering synchronize update" +[[ "$RELEASE_SCRIPT" == *"--branch"* ]] || fail "release job does not validate the bump branch" +[[ "$RELEASE_SCRIPT" != *"release-version-bump"* ]] || + fail "release job uses an unrecognized release label" +pass + +[ ! -e "$ROOT/utils/finalize-release-bump.sh" ] || + fail "obsolete human release-bump finalizer still exists" +WAITER_SCRIPT=$(<"$WAITER") +[[ "$WAITER_SCRIPT" == *'dd-octo-sts[bot]'* ]] || + fail "waiter does not require the STS approval" +[[ "$WAITER_SCRIPT" == *'--check)'* ]] || + fail "waiter does not accept explicit check names" +[[ "$WAITER_SCRIPT" != *'--required'* ]] || + fail "waiter still depends on repository-required checks" +[[ "$WAITER_SCRIPT" == *'RELEASE_BUMP_POLL_ATTEMPTS:-360'* ]] || + fail "waiter default does not allow 30 minutes for selected CI" +[[ "$WAITER_SCRIPT" == *'gh pr merge'* ]] || + fail "waiter does not merge after its gates" +[[ "$WAITER_SCRIPT" == *'--match-head-commit'* ]] || + fail "waiter merge is not SHA locked" +[[ "$WAITER_SCRIPT" == *"merged_at"* ]] || + fail "waiter does not verify the PR actually merged" +pass + +APPROVAL_WORKFLOW=$(<"$ROOT/.github/workflows/approve-trivial.yml") +VALIDATION_LINE=$(grep -n "Validate release bump before approval" \ + <<<"$APPROVAL_WORKFLOW" | cut -d: -f1) +FEDERATION_LINE=$(grep -n "uses: DataDog/dd-octo-sts-action" \ + <<<"$APPROVAL_WORKFLOW" | cut -d: -f1) +REVALIDATION_LINE=$(grep -n "Revalidate release bump immediately before approval" \ + <<<"$APPROVAL_WORKFLOW" | cut -d: -f1) +[ "$VALIDATION_LINE" -lt "$FEDERATION_LINE" ] || + fail "approval token is federated before validation" +[ "$FEDERATION_LINE" -lt "$REVALIDATION_LINE" ] || + fail "approval token is federated after the final validation" +[[ "$APPROVAL_WORKFLOW" == *"github.event.label.name == 'trivial'"* ]] || + fail "approval workflow does not trigger on trivial" +[[ "$APPROVAL_WORKFLOW" == *"github.event.sender.login"* ]] || + fail "approval workflow does not use the label event sender" +AUTHORIZATION_LINE=$(grep -n "Authorize trivial labeler" \ + <<<"$APPROVAL_WORKFLOW" | cut -d: -f1) +[ "$AUTHORIZATION_LINE" -lt "$FEDERATION_LINE" ] || + fail "STS token is federated before labeler authorization" +[[ "$APPROVAL_WORKFLOW" == *"steps.classify.outputs.release_bump == 'true'"* ]] || + fail "approval workflow does not conditionally validate release bumps" +[[ "$APPROVAL_WORKFLOW" == *"commit_id: process.env.EXPECTED_HEAD_SHA"* ]] || + fail "approval is not pinned to the validated SHA" +pass + +RELEASE_WORKFLOW=$(<"$ROOT/.github/workflows/release-validated.yml") +CI_WORKFLOW=$(<"$ROOT/.github/workflows/ci.yml") +APPROVAL_POLICY=$(<"$ROOT/.github/chainguard/self.approve-trivial.approve-pr.sts.yaml") +EXPECTED_APPROVAL_WORKFLOW_REF='job_workflow_ref: DataDog/java-profiler/\.github/workflows/approve-trivial\.yml@refs/heads/(main|release/[0-9]+\.[0-9]+\._)' +grep -Fqx " $EXPECTED_APPROVAL_WORKFLOW_REF" <<<"$APPROVAL_POLICY" || + fail "approval policy workflow identity does not allow protected release branches" +pass +[ ! -e "$ROOT/.github/chainguard/self.release-bump.create-pr.sts.yaml" ] || + fail "obsolete release PR token policy still exists" +[ -e "$ROOT/.github/chainguard/self.release-bump.label-pr.sts.yaml" ] || + fail "release bump label policy is missing" +[[ "$RELEASE_WORKFLOW" == *"self.release-bump.label-pr"* ]] || + fail "release workflow does not federate the label-only token" +[[ "$RELEASE_WORKFLOW" == *"wait-release-bump.sh"* ]] || + fail "release workflow does not run the gated merger" +[[ "$RELEASE_WORKFLOW" == *"--check release-bump-ci"* ]] || + fail "release workflow does not select the aggregate release-bump CI check" +[[ "$CI_WORKFLOW" == *"release-bump-ci:"* ]] || + fail "CI workflow does not provide the selected release-bump check" +[[ "$CI_WORKFLOW" == *"needs: [release-automation-tests, check-formatting, check-javadoc, test-matrix]"* ]] || + fail "release-bump CI check does not aggregate the selected jobs" +[[ "$RELEASE_WORKFLOW" != *"finalize-release-bump.sh"* ]] || + fail "release workflow still references the human finalizer" +grep -Fq "GITHUB_SHA\" != \"\$EXPECTED_SOURCE_SHA" <<<"$RELEASE_WORKFLOW" || + fail "release dispatch is not locked to the requested source SHA" +[[ "$RELEASE_WORKFLOW" == *"FIRST_PATCH=false"* ]] || + fail "release workflow does not track the first-patch state" +[[ "$RELEASE_WORKFLOW" == *"[ \"\$TYPE\" == \"patch\" ] && [ \"\$ALREADY_RELEASED\" == \"true\" ] &&"* ]] || + fail "release workflow does not distinguish the tagged first-patch base" +[[ "$RELEASE_WORKFLOW" == *"[ \"\$PATCH\" -eq 0 ]"* ]] || + fail "release workflow accepts an already-tagged nonzero patch as the first patch" +[[ "$RELEASE_WORKFLOW" == *"RELEASE_VERSION=\"\$MAJOR.\$MINOR.\$((PATCH + 1))\""* ]] || + fail "release workflow does not increment an already-tagged patch version" +[[ "$RELEASE_WORKFLOW" == *"RELEASE_VERSION=\"\$BASE\""* ]] || + fail "release workflow does not preserve an untagged patch development version" +pass + +# Exercise the completion gate with a deterministic gh stub. These scenarios +# prove that success requires the exact approval, selected checks, and an +# observed merge, while stale heads and failed checks fail closed. +WAITER_BIN="$TEMP_DIR/waiter-bin" +mkdir "$WAITER_BIN" +cat > "$WAITER_BIN/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +EXPECTED=$(printf 'a%.0s' {1..40}) +if [ "$1" = "api" ] && [[ "$*" == *"/reviews?"* ]]; then + echo approval >> "$WAITER_ORDER" + if [ "$WAITER_SCENARIO" = "stale-approval" ]; then + printf '[[{"user":{"login":"dd-octo-sts[bot]"},"state":"APPROVED","commit_id":"%s"}]]\n' \ + "$(printf 'b%.0s' {1..40})" + else + printf '[[{"user":{"login":"dd-octo-sts[bot]"},"state":"APPROVED","commit_id":"%s"}]]\n' \ + "$EXPECTED" + fi +elif [ "$1" = "api" ]; then + case "$WAITER_SCENARIO" in + head-change) + printf '{"head":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"state":"open","merged_at":null}\n' + ;; + closed) + printf '{"head":{"sha":"%s"},"state":"closed","merged_at":null}\n' "$EXPECTED" + ;; + already-merged) + printf '{"head":{"sha":"%s"},"state":"closed","merged_at":"2026-08-02T00:00:00Z"}\n' "$EXPECTED" + ;; + *) + if [ -f "$WAITER_MERGED" ]; then + printf '{"head":{"sha":"%s"},"state":"closed","merged_at":"2026-08-02T00:00:00Z"}\n' "$EXPECTED" + else + printf '{"head":{"sha":"%s"},"state":"open","merged_at":null}\n' "$EXPECTED" + fi + ;; + esac +elif [ "$1 $2" = "pr checks" ]; then + echo checks >> "$WAITER_ORDER" + case "$WAITER_SCENARIO" in + failed-check) printf '[{"name":"release-bump-ci","state":"FAILURE"}]\n' ;; + missing-check) printf '[]\n' ;; + pending-check) printf '[{"name":"release-bump-ci","state":"IN_PROGRESS"}]\n' ;; + *) printf '[{"name":"release-bump-ci","state":"SUCCESS"}]\n' ;; + esac +elif [ "$1 $2 $3" = "pr merge 698" ]; then + [[ "$*" == *"--squash --match-head-commit $EXPECTED"* ]] || exit 98 + echo merge >> "$WAITER_ORDER" + touch "$WAITER_MERGED" +else + echo "unsupported waiter gh call: $*" >&2 + exit 99 +fi +GH +chmod +x "$WAITER_BIN/gh" +WAITER_SHA=$(printf 'a%.0s' {1..40}) + +run_waiter() { + local scenario=$1 + rm -f "$TEMP_DIR/waiter-merged" "$TEMP_DIR/waiter-order" + WAITER_SCENARIO="$scenario" WAITER_MERGED="$TEMP_DIR/waiter-merged" \ + WAITER_ORDER="$TEMP_DIR/waiter-order" \ + RELEASE_BUMP_POLL_ATTEMPTS=1 RELEASE_BUMP_POLL_SECONDS=0 \ + PATH="$WAITER_BIN:$PATH" "$WAITER" \ + --repo DataDog/java-profiler --pr-number 698 \ + --expected-head-sha "$WAITER_SHA" \ + --check release-bump-ci >/dev/null +} + +run_waiter success || fail "waiter rejected exact approval/check/merge success" +[ "$(<"$TEMP_DIR/waiter-order")" = $'approval\nchecks\nmerge' ] || + fail "waiter did not enforce approval, checks, then merge ordering" +pass +if run_waiter already-merged 2>/dev/null; then + fail "waiter accepted a PR merged before its gates" +fi +pass +if run_waiter stale-approval 2>/dev/null; then + fail "waiter accepted a stale approval" +fi +pass +if run_waiter head-change 2>/dev/null; then + fail "waiter accepted a changed head" +fi +pass +if run_waiter failed-check 2>/dev/null; then + fail "waiter accepted a failed selected check" +fi +pass +if run_waiter missing-check 2>/dev/null; then + fail "waiter accepted a missing selected check" +fi +pass +if run_waiter pending-check 2>/dev/null; then + fail "waiter accepted a pending selected check" +fi +pass +if run_waiter closed 2>/dev/null; then + fail "waiter accepted a PR closed without merging" +fi +pass + +# Run the release script against a filesystem-backed bare remote. The gh and +# validator stubs enforce the command contract without network access. +REMOTE="$TEMP_DIR/remote.git" +WORK="$TEMP_DIR/work" +FAKE_BIN="$TEMP_DIR/bin" +mkdir "$FAKE_BIN" +git init --bare --quiet "$REMOTE" +git --git-dir="$REMOTE" config core.hooksPath /dev/null +git init --quiet -b main "$WORK" +mkdir -p "$WORK/.github/scripts" +cp "$ROOT/.github/scripts/release.sh" "$WORK/.github/scripts/release.sh" +cat > "$WORK/.github/scripts/validate-release-bump.sh" <<'VALIDATOR' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$VALIDATOR_CALLS" +if git --git-dir="$VALIDATOR_REMOTE" show-ref --tags --quiet; then + echo "release tag was published before bump validation" >&2 + exit 1 +fi +VALIDATOR +chmod +x "$WORK/.github/scripts/validate-release-bump.sh" +printf 'version = "1.48.0-SNAPSHOT"\n' > "$WORK/build.gradle.kts" +cat > "$WORK/gradlew" <<'GRADLE' +#!/usr/bin/env bash +set -euo pipefail +version=$(sed -nE 's/^version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/p' build.gradle.kts) +IFS=. read -r major minor patch <<<"$version" +case "$*" in + "printVersion -Psnapshot=false") + echo "Version: $version" + ;; + "incrementVersion --versionIncrementType=MINOR") + if [ "$minor" -ge 99 ]; then + major=$((major + 1)) + minor=0 + else + minor=$((minor + 1)) + fi + patch=0 + printf 'version = "%s.%s.%s-SNAPSHOT"\n' "$major" "$minor" "$patch" > build.gradle.kts + ;; + "incrementVersion --versionIncrementType=MAJOR") + major=$((major + 1)) + minor=0 + patch=0 + printf 'version = "%s.%s.%s-SNAPSHOT"\n' "$major" "$minor" "$patch" > build.gradle.kts + ;; + "incrementVersion --versionIncrementType=PATCH") + patch=$((patch + 1)) + printf 'version = "%s.%s.%s-SNAPSHOT"\n' "$major" "$minor" "$patch" > build.gradle.kts + ;; + *) + echo "unsupported fake Gradle call: $*" >&2 + exit 1 + ;; +esac +GRADLE +chmod +x "$WORK/gradlew" "$WORK/.github/scripts/release.sh" +cat > "$FAKE_BIN/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$GH_CALLS" +if [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */pulls ]]; then + [ "$GH_TOKEN" = "actions-token" ] || exit 98 + git --git-dir="$VALIDATOR_REMOTE" rev-parse refs/heads/automated/bump-1-49-0 > "$INITIAL_PR_HEAD" + printf '{"number":698,"html_url":"https://example.invalid/pull/698"}\n' +elif [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */issues/698/labels ]]; then + [ "$GH_TOKEN" = "label-token" ] || exit 98 + printf '[]\n' +else + echo "unsupported fake gh call: $*" >&2 + exit 99 +fi +GH +chmod +x "$FAKE_BIN/gh" +git -C "$WORK" config user.name "Release Test" +git -C "$WORK" config user.email "release-test@example.invalid" +git -C "$WORK" config commit.gpgsign false +git -C "$WORK" config tag.gpgsign false +git -C "$WORK" config core.hooksPath /dev/null +git -C "$WORK" add . +git -C "$WORK" commit --quiet -m initial +SOURCE_SHA=$(git -C "$WORK" rev-parse HEAD) +git -C "$WORK" remote add origin "$REMOTE" +git -C "$WORK" push --quiet -u origin main +( + cd "$WORK" + GITHUB_OUTPUT="$TEMP_DIR/release-outputs" GH_CALLS="$TEMP_DIR/gh-calls" \ + INITIAL_PR_HEAD="$TEMP_DIR/initial-pr-head" \ + VALIDATOR_CALLS="$TEMP_DIR/validator-calls" VALIDATOR_REMOTE="$REMOTE" \ + GITHUB_REPOSITORY=DataDog/java-profiler GITHUB_TOKEN=actions-token \ + BUMP_LABEL_TOKEN=label-token \ + PATH="$FAKE_BIN:$PATH" \ + ./.github/scripts/release.sh MINOR +) >"$TEMP_DIR/release.log" 2>&1 || { + tail -20 "$TEMP_DIR/release.log" >&2 + fail "release script failed against the local bare remote" + } +BUMP_SHA=$(git --git-dir="$REMOTE" rev-parse refs/heads/automated/bump-1-49-0) +INITIAL_PR_SHA=$(<"$TEMP_DIR/initial-pr-head") +[ "$INITIAL_PR_SHA" != "$BUMP_SHA" ] || + fail "PR creation was not followed by a synchronize-producing commit update" +[ "$(git --git-dir="$REMOTE" rev-parse "$INITIAL_PR_SHA^")" = "$SOURCE_SHA" ] || + fail "initial PR commit parent differs from the release source" +[ "$(git --git-dir="$REMOTE" rev-parse "$INITIAL_PR_SHA^{tree}")" = \ + "$(git --git-dir="$REMOTE" rev-parse "$BUMP_SHA^{tree}")" ] || + fail "temporary and canonical bump commits do not have identical trees" +grep -Fq "api --method POST repos/DataDog/java-profiler/pulls" "$TEMP_DIR/gh-calls" || + fail "release script did not create the PR" +grep -Fq "api --method POST repos/DataDog/java-profiler/issues/698/labels" "$TEMP_DIR/gh-calls" || + fail "release script did not label the PR" +if grep -Fq "pr merge" "$TEMP_DIR/gh-calls"; then + fail "release script merged before the external approval and CI gates" +fi +grep -Fq -- "--branch" "$TEMP_DIR/validator-calls" || + fail "release script did not validate the pushed bump branch" +grep -Fq -- "--base main" "$TEMP_DIR/validator-calls" || + fail "release script did not validate the bump base" +grep -Fq -- "--head automated/bump-1-49-0" "$TEMP_DIR/validator-calls" || + fail "release script did not validate the bump head" +grep -Fq -- "--expected-head-sha $BUMP_SHA" "$TEMP_DIR/validator-calls" || + fail "branch validation did not pin the bump SHA" +[ "$(git --git-dir="$REMOTE" rev-parse "$BUMP_SHA^")" = "$SOURCE_SHA" ] || + fail "bump commit parent differs from the release source" +git --git-dir="$REMOTE" show-ref --verify --quiet refs/heads/release/1.48._ || + fail "release branch was not pushed" +git --git-dir="$REMOTE" show-ref --verify --quiet refs/tags/v_1.48.0 || + fail "release tag was not pushed" +grep -Fqx "source_sha=$SOURCE_SHA" "$TEMP_DIR/release-outputs" || + fail "release outputs omitted source SHA" +grep -Fqx "bump_head_sha=$BUMP_SHA" "$TEMP_DIR/release-outputs" || + fail "release outputs omitted bump SHA" +grep -Fqx "bump_pr_number=698" "$TEMP_DIR/release-outputs" || + fail "release outputs omitted bump PR number" +pass + +# Verify that the first patch advances the initially tagged X.Y.0 release +# branch to X.Y.1, tags that release commit, and proposes X.Y.2-SNAPSHOT. +PATCH_REMOTE="$TEMP_DIR/patch-remote.git" +PATCH_WORK="$TEMP_DIR/patch-work" +PATCH_BIN="$TEMP_DIR/patch-bin" +mkdir "$PATCH_BIN" +git init --bare --quiet "$PATCH_REMOTE" +git --git-dir="$PATCH_REMOTE" config core.hooksPath /dev/null +git init --quiet -b 'release/1.48._' "$PATCH_WORK" +mkdir -p "$PATCH_WORK/.github/scripts" +cp "$ROOT/.github/scripts/release.sh" "$PATCH_WORK/.github/scripts/release.sh" +cat > "$PATCH_WORK/.github/scripts/validate-release-bump.sh" <<'VALIDATOR' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$VALIDATOR_CALLS" +if git --git-dir="$VALIDATOR_REMOTE" show-ref --verify --quiet refs/tags/v_1.48.1; then + echo "first patch tag was published before bump validation" >&2 + exit 1 +fi +VALIDATOR +chmod +x "$PATCH_WORK/.github/scripts/validate-release-bump.sh" +printf 'version = "1.48.0-SNAPSHOT"\n' > "$PATCH_WORK/build.gradle.kts" +cp "$WORK/gradlew" "$PATCH_WORK/gradlew" +chmod +x "$PATCH_WORK/gradlew" "$PATCH_WORK/.github/scripts/release.sh" +cat > "$PATCH_BIN/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$GH_CALLS" +if [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */pulls ]]; then + [ "$GH_TOKEN" = "actions-token" ] || exit 98 + git --git-dir="$VALIDATOR_REMOTE" rev-parse refs/heads/automated/bump-1-48-2 > "$INITIAL_PR_HEAD" + printf '{"number":700,"html_url":"https://example.invalid/pull/700"}\n' +elif [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */issues/700/labels ]]; then + [ "$GH_TOKEN" = "label-token" ] || exit 98 + printf '[]\n' +else + echo "unsupported first-patch gh call: $*" >&2 + exit 99 +fi +GH +chmod +x "$PATCH_BIN/gh" +git -C "$PATCH_WORK" config user.name "Release Test" +git -C "$PATCH_WORK" config user.email "release-test@example.invalid" +git -C "$PATCH_WORK" config commit.gpgsign false +git -C "$PATCH_WORK" config tag.gpgsign false +git -C "$PATCH_WORK" config core.hooksPath /dev/null +git -C "$PATCH_WORK" add . +git -C "$PATCH_WORK" commit --quiet -m initial +PATCH_SOURCE_SHA=$(git -C "$PATCH_WORK" rev-parse HEAD) +git -C "$PATCH_WORK" tag -a v_1.48.0 -m 'Release v_1.48.0 (minor) from main' +git -C "$PATCH_WORK" remote add origin "$PATCH_REMOTE" +git -C "$PATCH_WORK" push --quiet -u origin 'release/1.48._' +git -C "$PATCH_WORK" push --quiet origin v_1.48.0 +( + cd "$PATCH_WORK" + GITHUB_OUTPUT="$TEMP_DIR/patch-outputs" GH_CALLS="$TEMP_DIR/patch-gh-calls" \ + INITIAL_PR_HEAD="$TEMP_DIR/patch-initial-pr-head" \ + VALIDATOR_CALLS="$TEMP_DIR/patch-validator-calls" VALIDATOR_REMOTE="$PATCH_REMOTE" \ + GITHUB_REPOSITORY=DataDog/java-profiler GITHUB_TOKEN=actions-token \ + BUMP_LABEL_TOKEN=label-token \ + PATH="$PATCH_BIN:$PATH" \ + ./.github/scripts/release.sh PATCH +) >"$TEMP_DIR/patch-release.log" 2>&1 || { + tail -20 "$TEMP_DIR/patch-release.log" >&2 + fail "first patch release script failed against the local bare remote" + } +PATCH_RELEASE_SHA=$(git --git-dir="$PATCH_REMOTE" rev-parse 'refs/heads/release/1.48._') +PATCH_BUMP_SHA=$(git --git-dir="$PATCH_REMOTE" rev-parse refs/heads/automated/bump-1-48-2) +[ "$(git --git-dir="$PATCH_REMOTE" show "$PATCH_RELEASE_SHA:build.gradle.kts")" = \ + 'version = "1.48.1-SNAPSHOT"' ] || + fail "first patch release branch does not contain 1.48.1" +[ "$(git --git-dir="$PATCH_REMOTE" rev-parse 'refs/tags/v_1.48.1^{commit}')" = \ + "$PATCH_RELEASE_SHA" ] || fail "first patch tag does not identify the release commit" +[ "$(git --git-dir="$PATCH_REMOTE" rev-parse "$PATCH_RELEASE_SHA^")" = "$PATCH_SOURCE_SHA" ] || + fail "first patch release commit parent differs from the selected source" +[ "$(git --git-dir="$PATCH_REMOTE" show "$PATCH_BUMP_SHA:build.gradle.kts")" = \ + 'version = "1.48.2-SNAPSHOT"' ] || + fail "first patch bump does not contain 1.48.2-SNAPSHOT" +[ "$(git --git-dir="$PATCH_REMOTE" rev-parse "$PATCH_BUMP_SHA^")" = "$PATCH_RELEASE_SHA" ] || + fail "first patch bump parent is not the release commit" +grep -Fq -- "--first-patch" "$TEMP_DIR/patch-validator-calls" || + fail "first patch preflight did not select first-patch validation" +grep -Fq -- "--source-sha $PATCH_SOURCE_SHA" "$TEMP_DIR/patch-validator-calls" || + fail "first patch preflight did not preserve the selected source SHA" +grep -Fq -- "--local-release-tag v_1.48.1" "$TEMP_DIR/patch-validator-calls" || + fail "first patch preflight did not validate the new annotated tag" +grep -Fqx "release_version=1.48.1" "$TEMP_DIR/patch-outputs" || + fail "first patch outputs omitted the release version" +grep -Fqx "next_version=1.48.2" "$TEMP_DIR/patch-outputs" || + fail "first patch outputs omitted the next development version" +pass + +STUCK_PATCH_WORK="$TEMP_DIR/stuck-patch-work" +git clone --quiet --branch 'release/1.48._' "$PATCH_REMOTE" "$STUCK_PATCH_WORK" +if ( + cd "$STUCK_PATCH_WORK" + PATH="$NETWORK_GUARD_BIN:$PATH" ./.github/scripts/release.sh PATCH +) >"$TEMP_DIR/stuck-patch.log" 2>&1; then + fail "release script accepted an already-tagged nonzero patch version" +fi +grep -Fq 'is stuck at version 1.48.1' "$TEMP_DIR/stuck-patch.log" || + fail "release script did not preserve the stuck post-patch bump guard" +pass + +# Verify major releases keep their generated release commit off protected main. +# The bump moves main directly from the recorded source to the next development +# series, while the release branch and annotated tag identify the 2.0.0 commit. +MAJOR_REMOTE="$TEMP_DIR/major-remote.git" +MAJOR_WORK="$TEMP_DIR/major-work" +MAJOR_BIN="$TEMP_DIR/major-bin" +mkdir "$MAJOR_BIN" +git init --bare --quiet "$MAJOR_REMOTE" +git --git-dir="$MAJOR_REMOTE" config core.hooksPath /dev/null +git init --quiet -b main "$MAJOR_WORK" +mkdir -p "$MAJOR_WORK/.github/scripts" +cp "$ROOT/.github/scripts/release.sh" "$MAJOR_WORK/.github/scripts/release.sh" +cp "$WORK/.github/scripts/validate-release-bump.sh" "$MAJOR_WORK/.github/scripts/validate-release-bump.sh" +printf 'version = "1.48.0-SNAPSHOT"\n' > "$MAJOR_WORK/build.gradle.kts" +cp "$WORK/gradlew" "$MAJOR_WORK/gradlew" +chmod +x "$MAJOR_WORK/.github/scripts/release.sh" +cat > "$MAJOR_BIN/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$GH_CALLS" +if [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */pulls ]]; then + [ "$GH_TOKEN" = "actions-token" ] || exit 98 + git --git-dir="$VALIDATOR_REMOTE" rev-parse refs/heads/automated/bump-2-1-0 > "$INITIAL_PR_HEAD" + printf '{"number":699,"html_url":"https://example.invalid/pull/699"}\n' +elif [ "$1 $2 $3" = "api --method POST" ] && [[ "$4" == */issues/699/labels ]]; then + [ "$GH_TOKEN" = "label-token" ] || exit 98 + printf '[]\n' +elif [ "$1 $2 $3" = "pr merge 699" ]; then + [ "$GH_TOKEN" = "actions-token" ] || exit 98 + : +else + echo "unsupported fake gh call: $*" >&2 + exit 99 +fi +GH +chmod +x "$MAJOR_BIN/gh" +git -C "$MAJOR_WORK" config user.name "Release Test" +git -C "$MAJOR_WORK" config user.email "release-test@example.invalid" +git -C "$MAJOR_WORK" config commit.gpgsign false +git -C "$MAJOR_WORK" config tag.gpgsign false +git -C "$MAJOR_WORK" config core.hooksPath /dev/null +git -C "$MAJOR_WORK" add . +git -C "$MAJOR_WORK" commit --quiet -m initial +MAJOR_INITIAL_SHA=$(git -C "$MAJOR_WORK" rev-parse HEAD) +git -C "$MAJOR_WORK" remote add origin "$MAJOR_REMOTE" +git -C "$MAJOR_WORK" push --quiet -u origin main +( + cd "$MAJOR_WORK" + GITHUB_OUTPUT="$TEMP_DIR/major-outputs" GH_CALLS="$TEMP_DIR/major-gh-calls" \ + INITIAL_PR_HEAD="$TEMP_DIR/major-initial-pr-head" \ + VALIDATOR_CALLS="$TEMP_DIR/major-validator-calls" VALIDATOR_REMOTE="$MAJOR_REMOTE" \ + GITHUB_REPOSITORY=DataDog/java-profiler GITHUB_TOKEN=actions-token \ + BUMP_LABEL_TOKEN=label-token \ + PATH="$MAJOR_BIN:$PATH" \ + ./.github/scripts/release.sh MAJOR +) >"$TEMP_DIR/major-release.log" 2>&1 || { + tail -20 "$TEMP_DIR/major-release.log" >&2 + fail "major release script failed against the local bare remote" + } +MAJOR_MAIN_SHA=$(git --git-dir="$MAJOR_REMOTE" rev-parse refs/heads/main) +MAJOR_RELEASE_SHA=$(git --git-dir="$MAJOR_REMOTE" rev-parse refs/heads/release/2.0._) +MAJOR_BUMP_SHA=$(git --git-dir="$MAJOR_REMOTE" rev-parse refs/heads/automated/bump-2-1-0) +MAJOR_INITIAL_PR_SHA=$(<"$TEMP_DIR/major-initial-pr-head") +[ "$MAJOR_INITIAL_PR_SHA" != "$MAJOR_BUMP_SHA" ] || + fail "major bump did not produce a synchronize update" +[ "$MAJOR_MAIN_SHA" = "$MAJOR_INITIAL_SHA" ] || + fail "major release pushed its generated release commit to protected main" +[ "$(git --git-dir="$MAJOR_REMOTE" show "$MAJOR_RELEASE_SHA:build.gradle.kts")" = \ + 'version = "2.0.0-SNAPSHOT"' ] || + fail "major release branch does not contain the release version" +[ "$(git --git-dir="$MAJOR_REMOTE" show "$MAJOR_BUMP_SHA:build.gradle.kts")" = \ + 'version = "2.1.0-SNAPSHOT"' ] || + fail "major bump does not contain the next development version" +[ "$(git --git-dir="$MAJOR_REMOTE" rev-parse "$MAJOR_RELEASE_SHA^")" = "$MAJOR_INITIAL_SHA" ] || + fail "major release commit parent is not the recorded main source" +[ "$(git --git-dir="$MAJOR_REMOTE" rev-parse "$MAJOR_BUMP_SHA^")" = "$MAJOR_INITIAL_SHA" ] || + fail "major bump parent is not the recorded main source" +[ "$(git --git-dir="$MAJOR_REMOTE" rev-parse 'refs/tags/v_2.0.0^{commit}')" = "$MAJOR_RELEASE_SHA" ] || + fail "major release tag does not point to the release commit" +grep -Fqx "source_sha=$MAJOR_INITIAL_SHA" "$TEMP_DIR/major-outputs" || + fail "major release outputs changed the recorded main source SHA" +grep -Fq -- "--source-sha $MAJOR_INITIAL_SHA" "$TEMP_DIR/major-validator-calls" || + fail "major branch validation did not use the original main source" +grep -Fq -- "--expected-head-sha $MAJOR_BUMP_SHA" "$TEMP_DIR/major-validator-calls" || + fail "major branch validation did not pin the bump SHA" +grep -Fq -- "--local-release-tag v_2.0.0" "$TEMP_DIR/major-validator-calls" || + fail "major preflight did not validate the local annotated release tag" +if grep -Fq "pr merge" "$TEMP_DIR/major-gh-calls"; then + fail "major release script merged before the external approval and CI gates" +fi +pass + +echo "PASS: $TESTS hermetic release-automation test groups" diff --git a/.github/scripts/validate-release-bump.sh b/.github/scripts/validate-release-bump.sh new file mode 100755 index 0000000000..3d81e0fe04 --- /dev/null +++ b/.github/scripts/validate-release-bump.sh @@ -0,0 +1,509 @@ +#!/usr/bin/env bash + +set -euo pipefail + +LABEL="trivial" +MODE="pr" +FIXTURE="" +REPO="DataDog/java-profiler" +PR_NUMBER="" +SENDER="" +SENDER_TYPE="" +BASE="" +HEAD="" +SOURCE_SHA="" +EXPECTED_HEAD_SHA="" +LOCAL_RELEASE_TAG="" +FIRST_PATCH=false +TEMP_DIR="" + +die() { + echo "release-bump validation failed: $*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +while [ "$#" -gt 0 ]; do + case "$1" in + --fixture) FIXTURE=${2:-}; shift 2 ;; + --repo) REPO=${2:-}; shift 2 ;; + --pr-number) PR_NUMBER=${2:-}; shift 2 ;; + --sender) SENDER=${2:-}; shift 2 ;; + --sender-type) SENDER_TYPE=${2:-}; shift 2 ;; + --branch) MODE="branch"; shift ;; + --base) BASE=${2:-}; shift 2 ;; + --head) HEAD=${2:-}; shift 2 ;; + --source-sha) SOURCE_SHA=${2:-}; shift 2 ;; + --expected-head-sha) EXPECTED_HEAD_SHA=${2:-}; shift 2 ;; + --local-release-tag) LOCAL_RELEASE_TAG=${2:-}; shift 2 ;; + --first-patch) FIRST_PATCH=true; shift ;; + *) die "unknown argument: $1" ;; + esac +done + +command -v jq >/dev/null || die "jq is required" +TEMP_DIR=$(mktemp -d) +DATA="$TEMP_DIR/data.json" +PARENT_BUILD="$TEMP_DIR/parent-build.gradle.kts" +HEAD_BUILD="$TEMP_DIR/head-build.gradle.kts" +RELEASE_BUILD="$TEMP_DIR/release-build.gradle.kts" +SOURCE_BUILD="$TEMP_DIR/source-build.gradle.kts" + +permission() { + gh api "repos/$REPO/collaborators/$1/permission" --jq '.permission' +} + +content() { + gh api -H "Accept: application/vnd.github.raw+json" \ + "repos/$REPO/contents/build.gradle.kts?ref=$1" +} + +is_reachable() { + local status + status=$(gh api "repos/$REPO/compare/$1...$2" --jq '.status') + [ "$status" = "ahead" ] || [ "$status" = "identical" ] +} + +if [ -n "$FIXTURE" ]; then + [ -f "$FIXTURE" ] || die "fixture does not exist: $FIXTURE" + jq 'del(.parent_build, .head_build, .release_build, .source_build)' "$FIXTURE" > "$DATA" + jq -j '.parent_build' "$FIXTURE" > "$PARENT_BUILD" + jq -j '.head_build' "$FIXTURE" > "$HEAD_BUILD" + jq -j '.release_build // ""' "$FIXTURE" > "$RELEASE_BUILD" + jq -j '.source_build // ""' "$FIXTURE" > "$SOURCE_BUILD" +elif [ "$MODE" = "pr" ]; then + if [ -z "$PR_NUMBER" ] || [ -z "$SENDER" ] || [ -z "$SENDER_TYPE" ]; then + die "PR validation requires --pr-number, --sender, and --sender-type" + fi + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || die "invalid PR number" + + PR=$(gh api "repos/$REPO/pulls/$PR_NUMBER") + AUTHOR=$(jq -er '.user.login' <<<"$PR") + HEAD_SHA=$(jq -er '.head.sha' <<<"$PR") + COMMIT=$(gh api "repos/$REPO/commits/$HEAD_SHA") + PARENTS=$(jq -c '[.parents[].sha]' <<<"$COMMIT") + PARENT_SHA=$(jq -r 'if length == 1 then .[0] else "" end' <<<"$PARENTS") + FILES=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" \ + --slurp | jq -c 'add | map({filename, status})') + CURRENT_HEAD_SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') + SENDER_PERMISSION="none" + if [ "$SENDER" != "dd-octo-sts[bot]" ] || [ "$SENDER_TYPE" != "Bot" ]; then + SENDER_PERMISSION=$(permission "$SENDER") + fi + PARENT_REACHABLE=false + if [ -n "$PARENT_SHA" ] && + is_reachable "$PARENT_SHA" "$(jq -er '.base.sha' <<<"$PR")"; then + PARENT_REACHABLE=true + content "$PARENT_SHA" > "$PARENT_BUILD" + else + : > "$PARENT_BUILD" + fi + content "$HEAD_SHA" > "$HEAD_BUILD" + + jq -n \ + --arg repo "$REPO" \ + --arg sender_login "$SENDER" \ + --arg sender_type "$SENDER_TYPE" \ + --arg sender_permission "$SENDER_PERMISSION" \ + --arg author_login "$AUTHOR" \ + --arg author_type "$(jq -er '.user.type' <<<"$PR")" \ + --arg state "$(jq -er '.state' <<<"$PR")" \ + --argjson draft "$(jq -e '.draft' <<<"$PR")" \ + --arg title "$(jq -er '.title' <<<"$PR")" \ + --arg base_ref "$(jq -er '.base.ref' <<<"$PR")" \ + --arg base_sha "$(jq -er '.base.sha' <<<"$PR")" \ + --arg head_ref "$(jq -er '.head.ref' <<<"$PR")" \ + --arg head_sha "$HEAD_SHA" \ + --arg current_head_sha "$CURRENT_HEAD_SHA" \ + --arg head_repo "$(jq -r '.head.repo.full_name // ""' <<<"$PR")" \ + --argjson labels "$(jq -c '[.labels[].name]' <<<"$PR")" \ + --argjson changed_files "$FILES" \ + --argjson head_parents "$PARENTS" \ + --arg parent_sha "$PARENT_SHA" \ + --argjson parent_reachable "$PARENT_REACHABLE" \ + '{ + repo: $repo, + sender_login: $sender_login, + sender_type: $sender_type, + sender_permission: $sender_permission, + author_login: $author_login, + author_type: $author_type, + state: $state, + draft: $draft, + title: $title, + base_ref: $base_ref, + base_sha: $base_sha, + head_ref: $head_ref, + head_sha: $head_sha, + current_head_sha: $current_head_sha, + head_repo: $head_repo, + labels: $labels, + changed_files: $changed_files, + head_parents: $head_parents, + parent_sha: $parent_sha, + parent_reachable: $parent_reachable + }' > "$DATA" +else + if [ -z "$BASE" ] || [ -z "$HEAD" ] || [ -z "$SOURCE_SHA" ] || + [ -z "$EXPECTED_HEAD_SHA" ]; then + die "--branch requires --base, --head, --source-sha, and --expected-head-sha" + fi + + BASE_SHA=$(gh api "repos/$REPO/git/ref/heads/$BASE" --jq '.object.sha') + HEAD_SHA=$(gh api "repos/$REPO/git/ref/heads/$HEAD" --jq '.object.sha') + COMMIT=$(gh api "repos/$REPO/commits/$HEAD_SHA") + PARENTS=$(jq -c '[.parents[].sha]' <<<"$COMMIT") + PARENT_SHA=$(jq -r 'if length == 1 then .[0] else "" end' <<<"$PARENTS") + FILES=$(jq -c '[.files[] | {filename, status}]' <<<"$COMMIT") + PARENT_REACHABLE=false + if is_reachable "$SOURCE_SHA" "$BASE_SHA"; then + PARENT_REACHABLE=true + fi + if [ -n "$PARENT_SHA" ]; then + content "$PARENT_SHA" > "$PARENT_BUILD" + else + : > "$PARENT_BUILD" + fi + if [ "$FIRST_PATCH" = "true" ]; then + content "$SOURCE_SHA" > "$SOURCE_BUILD" + else + : > "$SOURCE_BUILD" + fi + content "$HEAD_SHA" > "$HEAD_BUILD" + + jq -n \ + --arg repo "$REPO" \ + --arg base_ref "$BASE" \ + --arg base_sha "$BASE_SHA" \ + --arg head_ref "$HEAD" \ + --arg head_sha "$HEAD_SHA" \ + --arg current_head_sha "$HEAD_SHA" \ + --arg expected_head_sha "$EXPECTED_HEAD_SHA" \ + --arg head_repo "$REPO" \ + --argjson changed_files "$FILES" \ + --argjson head_parents "$PARENTS" \ + --arg parent_sha "$PARENT_SHA" \ + --arg source_sha "$SOURCE_SHA" \ + --argjson parent_reachable "$PARENT_REACHABLE" \ + '{ + repo: $repo, + base_ref: $base_ref, + base_sha: $base_sha, + head_ref: $head_ref, + head_sha: $head_sha, + current_head_sha: $current_head_sha, + expected_head_sha: $expected_head_sha, + head_repo: $head_repo, + changed_files: $changed_files, + head_parents: $head_parents, + parent_sha: $parent_sha, + source_sha: $source_sha, + parent_reachable: $parent_reachable + }' > "$DATA" +fi + +json_string() { + jq -er "$1" "$DATA" +} + +if [ -n "$EXPECTED_HEAD_SHA" ]; then + [ "$(json_string '.head_sha')" = "$EXPECTED_HEAD_SHA" ] || + die "head SHA differs from the expected release commit" +fi +if [ -n "$SOURCE_SHA" ] && [ "$MODE" = "pr" ]; then + [ "$(json_string '.parent_sha')" = "$SOURCE_SHA" ] || + die "bump parent differs from the expected release source" +fi + +BASE_REF=$(json_string '.base_ref') +if [ "$BASE_REF" = "main" ]; then + INCREMENT="minor" +elif [[ "$BASE_REF" =~ ^release/[0-9]+\.[0-9]+\._$ ]]; then + INCREMENT="patch" +else + die "base branch must be main or release/X.Y._" +fi + +HEAD_REF=$(json_string '.head_ref') +[[ "$HEAD_REF" =~ ^automated/bump-([0-9]+)-([0-9]+)-([0-9]+)$ ]] || + die "head branch must match automated/bump-X-Y-Z" +BRANCH_MAJOR=${BASH_REMATCH[1]} +BRANCH_MINOR=${BASH_REMATCH[2]} +BRANCH_PATCH=${BASH_REMATCH[3]} + +[ "$(json_string '.head_repo')" = "$(json_string '.repo')" ] || + die "head branch must belong to the base repository" +[ "$(json_string '.head_sha')" = "$(json_string '.current_head_sha')" ] || + die "head SHA changed during validation" +if jq -e 'has("expected_head_sha")' "$DATA" >/dev/null; then + [ "$(json_string '.head_sha')" = "$(json_string '.expected_head_sha')" ] || + die "head SHA differs from the release workflow output" +fi +[ "$(jq -r '.head_parents | length' "$DATA")" -eq 1 ] || + die "bump commit must have exactly one parent" +[ "$(jq -r '.head_parents[0]' "$DATA")" = "$(json_string '.parent_sha')" ] || + die "bump commit parent differs from the recorded release source" +if [ "$MODE" = "branch" ] && [ "$FIRST_PATCH" != "true" ]; then + [ "$(json_string '.parent_sha')" = "$SOURCE_SHA" ] || + die "bump commit parent differs from the requested release source" +fi +jq -e '.parent_reachable == true' "$DATA" >/dev/null || + die "release source is not reachable from the current base branch" +jq -e '.changed_files == [{filename: "build.gradle.kts", status: "modified"}]' \ + "$DATA" >/dev/null || die "PR must modify only build.gradle.kts" + +VERSION_PATTERN='^version = "[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT"$' +[ "$(grep -Ec "$VERSION_PATTERN" "$PARENT_BUILD" || true)" -eq 1 ] || + die "parent build.gradle.kts must contain exactly one canonical root version line" +[ "$(grep -Ec "$VERSION_PATTERN" "$HEAD_BUILD" || true)" -eq 1 ] || + die "head build.gradle.kts must contain exactly one canonical root version line" + +PARENT_LINE=$(grep -En "$VERSION_PATTERN" "$PARENT_BUILD") +HEAD_LINE=$(grep -En "$VERSION_PATTERN" "$HEAD_BUILD") +[ "${PARENT_LINE%%:*}" = "${HEAD_LINE%%:*}" ] || die "root version line moved" +PARENT_VERSION=$(sed -E 's/^[0-9]+:version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/' \ + <<<"$PARENT_LINE") +HEAD_VERSION=$(sed -E 's/^[0-9]+:version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/' \ + <<<"$HEAD_LINE") + +sed -E "s/^version = \"[0-9]+\\.[0-9]+\\.[0-9]+-SNAPSHOT\"$/version = \"$PARENT_VERSION-SNAPSHOT\"/" \ + "$HEAD_BUILD" > "$TEMP_DIR/normalized-head.gradle.kts" +cmp -s "$PARENT_BUILD" "$TEMP_DIR/normalized-head.gradle.kts" || + die "build.gradle.kts contains changes other than the root version" + +IFS=. read -r PARENT_MAJOR PARENT_MINOR PARENT_PATCH <<<"$PARENT_VERSION" + +validate_first_patch_release() { + [ "$MODE" = "branch" ] || die "--first-patch is allowed only for branch preflight validation" + [ -n "$LOCAL_RELEASE_TAG" ] || die "first patch validation requires --local-release-tag" + [ "$(json_string '.source_sha')" = "$SOURCE_SHA" ] || + die "first patch source differs from the requested release source" + + local release_tag_sha release_tag_annotated release_parents release_changed_files + local source_tag_annotated source_tag_reachable + if [ -n "$FIXTURE" ]; then + release_tag_sha=$(json_string '.release_tag_sha') + release_tag_annotated=$(jq -er '.release_tag_annotated' "$DATA") + release_parents=$(jq -c '.release_parents' "$DATA") + release_changed_files=$(jq -c '.release_changed_files' "$DATA") + source_tag_annotated=$(jq -er '.source_tag_annotated' "$DATA") + source_tag_reachable=$(jq -er '.source_tag_reachable' "$DATA") + else + [ "$(git cat-file -t "refs/tags/$LOCAL_RELEASE_TAG" 2>/dev/null)" = "tag" ] || + die "first patch release tag $LOCAL_RELEASE_TAG must be annotated" + release_tag_sha=$(git rev-parse "refs/tags/$LOCAL_RELEASE_TAG^{commit}") + release_tag_annotated=true + + local release_commit source_tag + release_commit=$(gh api "repos/$REPO/commits/$(json_string '.parent_sha')") + release_parents=$(jq -c '[.parents[].sha]' <<<"$release_commit") + release_changed_files=$(jq -c '[.files[] | {filename, status}]' <<<"$release_commit") + + source_tag="v_$(sed -nE \ + 's/^version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/p' "$SOURCE_BUILD")" + if [ "$(git cat-file -t "refs/tags/$source_tag" 2>/dev/null)" = "tag" ]; then + source_tag_annotated=true + else + source_tag_annotated=false + fi + if [ "$source_tag_annotated" = "true" ] && + git merge-base --is-ancestor "refs/tags/$source_tag^{commit}" "$SOURCE_SHA"; then + source_tag_reachable=true + else + source_tag_reachable=false + fi + fi + + [ "$LOCAL_RELEASE_TAG" = "v_$PARENT_VERSION" ] || + die "first patch release tag must be v_$PARENT_VERSION" + [ "$release_tag_annotated" = "true" ] || + die "first patch release tag v_$PARENT_VERSION must be annotated" + [ "$release_tag_sha" = "$(json_string '.parent_sha')" ] || + die "first patch release tag must identify the bump parent" + [ "$(jq -r 'length' <<<"$release_parents")" -eq 1 ] || + die "first patch release commit must have exactly one parent" + [ "$(jq -r '.[0]' <<<"$release_parents")" = "$SOURCE_SHA" ] || + die "first patch release commit parent differs from the requested source" + jq -e '. == [{filename: "build.gradle.kts", status: "modified"}]' \ + <<<"$release_changed_files" >/dev/null || + die "first patch release commit must modify only build.gradle.kts" + [ "$source_tag_annotated" = "true" ] || + die "first patch source tag must be annotated" + [ "$source_tag_reachable" = "true" ] || + die "first patch source tag is not reachable from the requested source" + + [ "$(grep -Ec "$VERSION_PATTERN" "$SOURCE_BUILD" || true)" -eq 1 ] || + die "first patch source build.gradle.kts must contain one canonical root version line" + local source_line source_version source_major source_minor source_patch + source_line=$(grep -En "$VERSION_PATTERN" "$SOURCE_BUILD") + [ "${source_line%%:*}" = "${PARENT_LINE%%:*}" ] || + die "first patch root version line moved" + source_version=$(sed -E \ + 's/^[0-9]+:version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/' \ + <<<"$source_line") + IFS=. read -r source_major source_minor source_patch <<<"$source_version" + [ "$source_patch" -eq 0 ] || die "first patch source version must end in .0" + [ "$PARENT_MAJOR.$PARENT_MINOR.$PARENT_PATCH" = \ + "$source_major.$source_minor.$((source_patch + 1))" ] || + die "first patch release version must increment the source patch once" + sed -E \ + "s/^version = \"[0-9]+\\.[0-9]+\\.[0-9]+-SNAPSHOT\"$/version = \"$source_version-SNAPSHOT\"/" \ + "$PARENT_BUILD" > "$TEMP_DIR/normalized-first-patch.gradle.kts" + cmp -s "$SOURCE_BUILD" "$TEMP_DIR/normalized-first-patch.gradle.kts" || + die "first patch release commit contains changes other than the root version" +} + +if [ "$FIRST_PATCH" = "true" ]; then + validate_first_patch_release +elif [ -n "$LOCAL_RELEASE_TAG" ] && [ "$MODE" != "branch" ]; then + die "--local-release-tag is allowed only for branch preflight validation" +fi + +validate_major_release() { + local release_major=$1 + local release_version="$release_major.0.0" + local release_branch="release/$release_major.0._" + local release_ref_sha release_tag_sha release_tag_annotated + local release_parents release_changed_files + + if [ -n "$FIXTURE" ]; then + release_ref_sha=$(json_string '.release_ref_sha') + release_tag_sha=$(json_string '.release_tag_sha') + release_tag_annotated=$(jq -er '.release_tag_annotated' "$DATA") + release_parents=$(jq -c '.release_parents' "$DATA") + release_changed_files=$(jq -c '.release_changed_files' "$DATA") + else + local tag_ref tag_object release_commit + release_ref_sha=$(gh api "repos/$REPO/git/ref/heads/$release_branch" --jq '.object.sha') + if [ -n "$LOCAL_RELEASE_TAG" ]; then + [ "$MODE" = "branch" ] || + die "--local-release-tag is allowed only for branch preflight validation" + [ "$LOCAL_RELEASE_TAG" = "v_$release_version" ] || + die "local major release tag must be v_$release_version" + [ "$(git cat-file -t "refs/tags/$LOCAL_RELEASE_TAG" 2>/dev/null)" = "tag" ] || + die "local major release tag $LOCAL_RELEASE_TAG must be annotated" + release_tag_sha=$(git rev-parse "refs/tags/$LOCAL_RELEASE_TAG^{commit}") + else + tag_ref=$(gh api "repos/$REPO/git/ref/tags/v_$release_version") + [ "$(jq -er '.object.type' <<<"$tag_ref")" = "tag" ] || + die "major release tag v_$release_version must be annotated" + tag_object=$(gh api "repos/$REPO/git/tags/$(jq -er '.object.sha' <<<"$tag_ref")") + [ "$(jq -er '.object.type' <<<"$tag_object")" = "commit" ] || + die "major release tag v_$release_version must point to a commit" + release_tag_sha=$(jq -er '.object.sha' <<<"$tag_object") + fi + release_tag_annotated=true + release_commit=$(gh api "repos/$REPO/commits/$release_ref_sha") + release_parents=$(jq -c '[.parents[].sha]' <<<"$release_commit") + release_changed_files=$(jq -c '[.files[] | {filename, status}]' <<<"$release_commit") + content "$release_ref_sha" > "$RELEASE_BUILD" + fi + + [ "$release_tag_annotated" = "true" ] || + die "major release tag v_$release_version must be annotated" + [ "$release_ref_sha" = "$release_tag_sha" ] || + die "major release branch and tag must identify the same commit" + [ "$(jq -r 'length' <<<"$release_parents")" -eq 1 ] || + die "major release commit must have exactly one parent" + [ "$(jq -r '.[0]' <<<"$release_parents")" = "$(json_string '.parent_sha')" ] || + die "major release commit parent differs from the bump source" + jq -e '. == [{filename: "build.gradle.kts", status: "modified"}]' \ + <<<"$release_changed_files" >/dev/null || + die "major release commit must modify only build.gradle.kts" + + [ "$(grep -Ec "$VERSION_PATTERN" "$RELEASE_BUILD" || true)" -eq 1 ] || + die "major release build.gradle.kts must contain one canonical root version line" + local release_line actual_release_version + release_line=$(grep -En "$VERSION_PATTERN" "$RELEASE_BUILD") + [ "${release_line%%:*}" = "${PARENT_LINE%%:*}" ] || + die "major release root version line moved" + actual_release_version=$(sed -E \ + 's/^[0-9]+:version = "([0-9]+\.[0-9]+\.[0-9]+)-SNAPSHOT"$/\1/' \ + <<<"$release_line") + [ "$actual_release_version" = "$release_version" ] || + die "major release branch must contain $release_version-SNAPSHOT" + sed -E \ + "s/^version = \"[0-9]+\\.[0-9]+\\.[0-9]+-SNAPSHOT\"$/version = \"$PARENT_VERSION-SNAPSHOT\"/" \ + "$RELEASE_BUILD" > "$TEMP_DIR/normalized-release.gradle.kts" + cmp -s "$PARENT_BUILD" "$TEMP_DIR/normalized-release.gradle.kts" || + die "major release commit contains changes other than the root version" +} + +if [ "$INCREMENT" = "minor" ]; then + if [ "$PARENT_MINOR" -ge 99 ]; then + EXPECTED_MAJOR=$((10#$PARENT_MAJOR + 1)) + EXPECTED_MINOR=0 + else + EXPECTED_MAJOR=$((10#$PARENT_MAJOR)) + EXPECTED_MINOR=$((10#$PARENT_MINOR + 1)) + fi + EXPECTED_PATCH=0 + EXPECTED_VERSION="$EXPECTED_MAJOR.$EXPECTED_MINOR.$EXPECTED_PATCH" + if [ "$HEAD_VERSION" != "$EXPECTED_VERSION" ]; then + MAJOR_RELEASE_MAJOR=$((10#$PARENT_MAJOR + 1)) + MAJOR_RELEASE_BUMP="$MAJOR_RELEASE_MAJOR.1.0" + [ "$HEAD_VERSION" = "$MAJOR_RELEASE_BUMP" ] || + die "expected minor bump $PARENT_VERSION -> $EXPECTED_VERSION or validated major-release bump -> $MAJOR_RELEASE_BUMP, found $HEAD_VERSION" + validate_major_release "$MAJOR_RELEASE_MAJOR" + fi +else + SERIES=${BASE_REF#release/} + SERIES=${SERIES%._} + [ "$PARENT_MAJOR.$PARENT_MINOR" = "$SERIES" ] || + die "release branch series does not match the parent version" + if [ "$PARENT_PATCH" -ge 99 ]; then + if [ "$PARENT_MINOR" -ge 99 ]; then + EXPECTED_MAJOR=$((10#$PARENT_MAJOR + 1)) + EXPECTED_MINOR=0 + else + EXPECTED_MAJOR=$((10#$PARENT_MAJOR)) + EXPECTED_MINOR=$((10#$PARENT_MINOR + 1)) + fi + EXPECTED_PATCH=0 + else + EXPECTED_MAJOR=$((10#$PARENT_MAJOR)) + EXPECTED_MINOR=$((10#$PARENT_MINOR)) + EXPECTED_PATCH=$((10#$PARENT_PATCH + 1)) + fi + EXPECTED_VERSION="$EXPECTED_MAJOR.$EXPECTED_MINOR.$EXPECTED_PATCH" + [ "$HEAD_VERSION" = "$EXPECTED_VERSION" ] || + die "expected patch bump $PARENT_VERSION -> $EXPECTED_VERSION, found $HEAD_VERSION" +fi +[ "$BRANCH_MAJOR.$BRANCH_MINOR.$BRANCH_PATCH" = "$HEAD_VERSION" ] || + die "head branch version does not match build.gradle.kts" + +if [ "$MODE" = "pr" ]; then + if [ "$(json_string '.state')" != "open" ] || + ! jq -e '.draft == false' "$DATA" >/dev/null; then + die "PR must be open and ready for review" + fi + if [ "$(json_string '.sender_login')" = "dd-octo-sts[bot]" ] && + [ "$(json_string '.sender_type')" = "Bot" ]; then + : + else + [ "$(json_string '.sender_type')" = "User" ] || + die "labeling actor must be the release STS bot or a trusted human" + case "$(json_string '.sender_permission')" in + write|maintain|admin) ;; + *) die "human labeling actor must have write, maintain, or admin permission" ;; + esac + fi + if [ "$(json_string '.author_login')" != "github-actions[bot]" ] || + [ "$(json_string '.author_type')" != "Bot" ]; then + die "release bump PR must be created by github-actions[bot]" + fi + jq -e --arg expected_label "$LABEL" \ + '.labels | index($expected_label) != null' "$DATA" >/dev/null || + die "PR must have the $LABEL label" + EXPECTED_TITLE="[Automated] Bump dev version to $HEAD_VERSION" + [ "$(json_string '.title')" = "$EXPECTED_TITLE" ] || + die "PR title must be exactly: $EXPECTED_TITLE" +fi + +echo "Validated release bump $PARENT_VERSION -> $HEAD_VERSION at $(json_string '.head_sha')" diff --git a/.github/scripts/validate-trivial-approval.sh b/.github/scripts/validate-trivial-approval.sh new file mode 100755 index 0000000000..d3d4fec611 --- /dev/null +++ b/.github/scripts/validate-trivial-approval.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO="DataDog/java-profiler" +ACTOR="" +ACTOR_TYPE="" +FIXTURE="" + +die() { + echo "trivial approval validation failed: $*" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --repo) REPO=${2:-}; shift 2 ;; + --actor) ACTOR=${2:-}; shift 2 ;; + --actor-type) ACTOR_TYPE=${2:-}; shift 2 ;; + --fixture) FIXTURE=${2:-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ACTOR" ] || die "label actor is required" +[ -n "$ACTOR_TYPE" ] || die "label actor type is required" + +if [ -n "$FIXTURE" ]; then + command -v jq >/dev/null || die "jq is required" + [ -f "$FIXTURE" ] || die "fixture does not exist: $FIXTURE" + FIXTURE_ACTOR=$(jq -er '.actor' "$FIXTURE") + FIXTURE_ACTOR_TYPE=$(jq -er '.actor_type' "$FIXTURE") + FIXTURE_PERMISSION=$(jq -er '.permission' "$FIXTURE") + [ "$ACTOR" = "$FIXTURE_ACTOR" ] || die "fixture actor does not match label actor" + [ "$ACTOR_TYPE" = "$FIXTURE_ACTOR_TYPE" ] || + die "fixture actor type does not match label actor type" + PERMISSION=$FIXTURE_PERMISSION +else + command -v gh >/dev/null || die "GitHub CLI (gh) is required" + if ! PERMISSION=$(gh api "repos/$REPO/collaborators/$ACTOR/permission" \ + --jq '.permission' 2>/dev/null); then + die "unable to determine repository permission for label actor $ACTOR" + fi +fi + +case "$ACTOR" in + dependabot\[bot\]|dd-octo-sts\[bot\]) + [ "$ACTOR_TYPE" = "Bot" ] || die "trusted bot has an unexpected actor type" + echo "Authorized trusted bot labeler: $ACTOR" + exit 0 + ;; +esac + +[ "$ACTOR_TYPE" = "User" ] || die "label actor is not a trusted human or bot" + +case "$PERMISSION" in + write|maintain|admin) + echo "Authorized human labeler: $ACTOR ($PERMISSION)" + ;; + *) + die "label actor $ACTOR has insufficient permission: $PERMISSION" + ;; +esac diff --git a/.github/scripts/wait-release-bump.sh b/.github/scripts/wait-release-bump.sh new file mode 100755 index 0000000000..97213c5326 --- /dev/null +++ b/.github/scripts/wait-release-bump.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO="" +PR_NUMBER="" +EXPECTED_HEAD_SHA="" +CHECK_NAMES=() +POLL_ATTEMPTS=${RELEASE_BUMP_POLL_ATTEMPTS:-360} +POLL_SECONDS=${RELEASE_BUMP_POLL_SECONDS:-5} + +die() { + echo "release-bump completion failed: $*" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --repo) REPO=${2:-}; shift 2 ;; + --pr-number) PR_NUMBER=${2:-}; shift 2 ;; + --expected-head-sha) EXPECTED_HEAD_SHA=${2:-}; shift 2 ;; + --check) CHECK_NAMES+=("${2:-}"); shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +if [ -z "$REPO" ] || [ -z "$PR_NUMBER" ] || [ -z "$EXPECTED_HEAD_SHA" ]; then + die "--repo, --pr-number, and --expected-head-sha are required" +fi +[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || die "invalid PR number" +[ "${#CHECK_NAMES[@]}" -gt 0 ] || die "at least one --check is required" +for check_name in "${CHECK_NAMES[@]}"; do + [ -n "$check_name" ] || die "check names must not be empty" +done +[[ "$POLL_ATTEMPTS" =~ ^[1-9][0-9]*$ ]] || die "invalid poll attempts" +[[ "$POLL_SECONDS" =~ ^[0-9]+$ ]] || die "invalid poll interval" + +read_pr() { + gh api "repos/$REPO/pulls/$PR_NUMBER" +} + +verify_head() { + local pr=$1 + [ "$(jq -er '.head.sha' <<<"$pr")" = "$EXPECTED_HEAD_SHA" ] || + die "PR head changed from the validated release bump" +} + +verify_open() { + local pr=$1 + [ "$(jq -er '.state' <<<"$pr")" = "open" ] || + die "PR closed before approval, checks, and gated merge completed" +} + +for ((attempt = 1; attempt <= POLL_ATTEMPTS; attempt++)); do + PR=$(read_pr) + verify_head "$PR" + verify_open "$PR" + + if gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews?per_page=100" \ + --slurp | + jq -e --arg expected_head_sha "$EXPECTED_HEAD_SHA" \ + 'add | any(.user.login == "dd-octo-sts[bot]" and .state == "APPROVED" and .commit_id == $expected_head_sha)' \ + >/dev/null; then + APPROVED=true + break + fi + sleep "$POLL_SECONDS" +done + +[ "${APPROVED:-false}" = "true" ] || + die "timed out waiting for the exact-SHA STS approval" + +for ((attempt = 1; attempt <= POLL_ATTEMPTS; attempt++)); do + PR=$(read_pr) + verify_head "$PR" + verify_open "$PR" + + CHECKS=$(gh pr checks "$PR_NUMBER" --repo "$REPO" \ + --json name,state 2>/dev/null || true) + ALL_CHECKS_PASSED=true + for check_name in "${CHECK_NAMES[@]}"; do + CHECK_STATE=$(jq -r --arg name "$check_name" \ + '[.[] | select(.name == $name) | .state] | if length == 1 then .[0] else "" end' \ + <<<"${CHECKS:-[]}") + if [ "$CHECK_STATE" = "SUCCESS" ]; then + continue + fi + case "$CHECK_STATE" in + ""|PENDING|QUEUED|IN_PROGRESS|WAITING|REQUESTED) + ALL_CHECKS_PASSED=false + ;; + *) + die "selected check $check_name completed with state $CHECK_STATE" + ;; + esac + done + if [ "$ALL_CHECKS_PASSED" = "true" ]; then + break + fi + sleep "$POLL_SECONDS" +done +[ "${ALL_CHECKS_PASSED:-false}" = "true" ] || + die "timed out waiting for selected checks to pass" + +PR=$(read_pr) +verify_head "$PR" +verify_open "$PR" + +gh pr merge "$PR_NUMBER" --repo "$REPO" --squash \ + --match-head-commit "$EXPECTED_HEAD_SHA" + +for ((attempt = 1; attempt <= POLL_ATTEMPTS; attempt++)); do + PR=$(read_pr) + verify_head "$PR" + if [ "$(jq -r '.merged_at // ""' <<<"$PR")" != "" ]; then + echo "Release bump PR #$PR_NUMBER is merged at $EXPECTED_HEAD_SHA" + exit 0 + fi + [ "$(jq -er '.state' <<<"$PR")" = "open" ] || + die "PR closed without merging" + sleep "$POLL_SECONDS" +done + +die "approval and selected checks passed, but exact-SHA merge did not complete" diff --git a/.github/workflows/approve-trivial.yml b/.github/workflows/approve-trivial.yml index f737036d44..3c35214b06 100644 --- a/.github/workflows/approve-trivial.yml +++ b/.github/workflows/approve-trivial.yml @@ -1,29 +1,122 @@ -name: Auto-Approve Trivial PRs +name: Approve Trivial PRs on: pull_request_target: types: [labeled] jobs: - auto-approve: - if: contains(github.event.pull_request.labels.*.name, 'trivial') || contains(github.event.pull_request.labels.*.name, 'no-review') + approve: + if: github.event.label.name == 'trivial' runs-on: ubuntu-latest permissions: - id-token: write # Needed to federate tokens + contents: read + pull-requests: read + id-token: write steps: + - name: Checkout trusted validator + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Authorize trivial labeler + env: + GH_TOKEN: ${{ github.token }} + LABEL_ACTOR: ${{ github.event.sender.login }} + LABEL_ACTOR_TYPE: ${{ github.event.sender.type }} + run: | + ./.github/scripts/validate-trivial-approval.sh \ + --repo "$GITHUB_REPOSITORY" \ + --actor "$LABEL_ACTOR" \ + --actor-type "$LABEL_ACTOR_TYPE" + + - name: Classify pull request + id: classify + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if [[ "$HEAD_REF" =~ ^automated/bump-[0-9]+-[0-9]+-[0-9]+$ ]]; then + echo "release_bump=true" >> "$GITHUB_OUTPUT" + else + echo "release_bump=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate release bump before approval + if: steps.classify.outputs.release_bump == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + LABEL_ACTOR: ${{ github.event.sender.login }} + LABEL_ACTOR_TYPE: ${{ github.event.sender.type }} + run: | + ./.github/scripts/validate-release-bump.sh \ + --repo "$GITHUB_REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --sender "$LABEL_ACTOR" \ + --sender-type "$LABEL_ACTOR_TYPE" + + - name: Check for an existing exact approval + id: existing + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b # 9.0.0 + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100 + }); + const exists = reviews.some(review => + review.user?.login === 'dd-octo-sts[bot]' && + review.state === 'APPROVED' && + review.commit_id === process.env.EXPECTED_HEAD_SHA + ); + core.setOutput('approved', exists ? 'true' : 'false'); + - uses: DataDog/dd-octo-sts-action@96a25462dbcb10ebf0bfd6e2ccc917d2ab235b9a # v1.0.4 id: octo-sts + if: steps.existing.outputs.approved != 'true' with: scope: DataDog/java-profiler policy: self.approve-trivial.approve-pr - - name: Auto-approve PR - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # 9.0.0 + + - name: Revalidate release bump immediately before approval + if: steps.classify.outputs.release_bump == 'true' && steps.existing.outputs.approved != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + LABEL_ACTOR: ${{ github.event.sender.login }} + LABEL_ACTOR_TYPE: ${{ github.event.sender.type }} + run: | + ./.github/scripts/validate-release-bump.sh \ + --repo "$GITHUB_REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --sender "$LABEL_ACTOR" \ + --sender-type "$LABEL_ACTOR_TYPE" + + - name: Approve exact current commit + if: steps.existing.outputs.approved != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b # 9.0.0 + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} with: github-token: ${{ steps.octo-sts.outputs.token }} script: | + const pull = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + if (pull.data.head.sha !== process.env.EXPECTED_HEAD_SHA) { + core.setFailed('Head SHA changed after validation; refusing approval'); + return; + } await github.rest.pulls.createReview({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, + commit_id: process.env.EXPECTED_HEAD_SHA, event: 'APPROVE' - }) + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e113b7167..b0a6a294f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,34 @@ permissions: actions: read jobs: + release-automation-tests: + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate release automation without network access + run: | + bash -n \ + .github/scripts/release.sh \ + .github/scripts/validate-trivial-approval.sh \ + .github/scripts/validate-release-bump.sh \ + .github/scripts/wait-release-bump.sh \ + utils/release.sh \ + .github/scripts/tests/test_release_automation.sh + shellcheck \ + .github/scripts/release.sh \ + .github/scripts/validate-trivial-approval.sh \ + .github/scripts/validate-release-bump.sh \ + .github/scripts/wait-release-bump.sh \ + utils/release.sh \ + .github/scripts/tests/test_release_automation.sh + .github/scripts/tests/test_release_automation.sh + check-for-pr: runs-on: ubuntu-latest outputs: @@ -202,6 +230,34 @@ jobs: body-file: test-summary.md comment-id: ci-test-results + release-bump-ci: + needs: [release-automation-tests, check-formatting, check-javadoc, test-matrix] + if: >- + always() && + github.event_name == 'pull_request' && + startsWith(github.event.pull_request.head.ref, 'automated/bump-') + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Require all selected release-bump CI jobs + env: + AUTOMATION_RESULT: ${{ needs.release-automation-tests.result }} + FORMATTING_RESULT: ${{ needs.check-formatting.result }} + JAVADOC_RESULT: ${{ needs.check-javadoc.result }} + TEST_RESULT: ${{ needs.test-matrix.result }} + run: | + for result in \ + "$AUTOMATION_RESULT" \ + "$FORMATTING_RESULT" \ + "$JAVADOC_RESULT" \ + "$TEST_RESULT"; do + if [ "$result" != "success" ]; then + echo "::error::A selected release-bump CI job completed with result: $result" + exit 1 + fi + done + fuzz: needs: [check-for-pr, compute-configurations] if: needs.check-for-pr.outputs.skip != 'true' && needs.compute-configurations.outputs.run_fuzz == 'true' diff --git a/.github/workflows/release-validated.yml b/.github/workflows/release-validated.yml index eb70807820..2579a59843 100644 --- a/.github/workflows/release-validated.yml +++ b/.github/workflows/release-validated.yml @@ -1,5 +1,5 @@ name: Validated Release -run-name: "${{ inputs.dry_run && 'Dry-run for ' || 'Perform ' }}${{ inputs.release_type }} release of ${{ github.ref_name }} branch" +run-name: "${{ inputs.dry_run && 'Dry-run for ' || 'Perform ' }}${{ inputs.release_type }} release of ${{ github.ref_name }} branch [${{ inputs.request_id || 'manual' }}]" on: workflow_dispatch: @@ -23,11 +23,20 @@ on: required: false type: boolean default: false + request_id: + description: Unique correlation ID supplied by utils/release.sh + required: false + type: string + default: "" + source_sha: + description: Exact source SHA expected by utils/release.sh + required: false + type: string + default: "" permissions: contents: write actions: read - pull-requests: write jobs: validate-inputs: @@ -41,6 +50,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + ref: ${{ github.sha }} - name: Setup Java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 @@ -55,10 +65,16 @@ jobs: run: | BRANCH="${GITHUB_REF_NAME}" TYPE="${{ inputs.release_type }}" + EXPECTED_SOURCE_SHA="${{ inputs.source_sha }}" echo "Current branch: $BRANCH" echo "Release type: $TYPE" + if [ -n "$EXPECTED_SOURCE_SHA" ] && [ "$GITHUB_SHA" != "$EXPECTED_SOURCE_SHA" ]; then + echo "::error::Dispatched SHA $GITHUB_SHA differs from requested SHA $EXPECTED_SOURCE_SHA" + exit 1 + fi + # Branch validation if [ "$TYPE" == "patch" ] || [ "$TYPE" == "retag" ]; then if [[ ! $BRANCH =~ ^release/[0-9]+\.[0-9]+\._$ ]]; then @@ -95,12 +111,23 @@ jobs: echo "Version $BASE is not yet released" fi - # A non-retag release must never start from an already-tagged base: - # it means the post-release version-bump PR from the previous - # release never merged, so this branch is stuck. Fail loudly - # instead of silently skipping release creation or guessing at - # what the next version should be. - if [ "$TYPE" != "retag" ] && [ "$ALREADY_RELEASED" == "true" ]; then + # A release branch is initially created at the tagged X.Y.0 minor + # release. Its first patch legitimately starts from that tag and + # creates X.Y.1. A tagged patch version greater than zero instead + # means the previous post-release bump PR never merged. + FIRST_PATCH=false + if [ "$TYPE" == "patch" ] && [ "$ALREADY_RELEASED" == "true" ] && + [ "$PATCH" -eq 0 ]; then + if [ "$(git cat-file -t "refs/tags/v_${BASE}" 2>/dev/null)" != "tag" ]; then + echo "::error::First patch base v_${BASE} must be an annotated tag" + exit 1 + fi + if ! git merge-base --is-ancestor "v_${BASE}^{commit}" "$GITHUB_SHA"; then + echo "::error::First patch base tag v_${BASE} is not reachable from $GITHUB_SHA" + exit 1 + fi + FIRST_PATCH=true + elif [ "$TYPE" != "retag" ] && [ "$ALREADY_RELEASED" == "true" ]; then echo "::error::${BRANCH} is stuck at version ${BASE}, which is already tagged (v_${BASE})." echo "::error::The automated post-release version-bump PR for this branch was never merged." STUCK_PR=$(gh pr list --state open --base "$BRANCH" --json headRefName,url \ @@ -132,8 +159,14 @@ jobs: exit 1 fi else - # PATCH always increments - RELEASE_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + # The first patch starts from the already-tagged minor version and + # releases the next patch. Subsequent patch runs start from the + # untagged development version left by the previous bump PR. + if [ "$FIRST_PATCH" == "true" ]; then + RELEASE_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + else + RELEASE_VERSION="$BASE" + fi fi # Compute release branch @@ -174,9 +207,21 @@ jobs: if: always() && needs.validate-inputs.result == 'success' && (needs.pre-release-tests.result == 'success' || needs.pre-release-tests.result == 'skipped') runs-on: ubuntu-latest permissions: + checks: read contents: write pull-requests: write - id-token: write # Needed to federate a token for the bump-PR create-release step + statuses: read + id-token: write + outputs: + base_branch: ${{ steps.create-release.outputs.base_branch }} + source_sha: ${{ steps.create-release.outputs.source_sha }} + release_version: ${{ steps.create-release.outputs.release_version }} + next_version: ${{ steps.create-release.outputs.next_version }} + release_branch: ${{ steps.create-release.outputs.release_branch }} + bump_branch: ${{ steps.create-release.outputs.bump_branch }} + bump_head_sha: ${{ steps.create-release.outputs.bump_head_sha }} + bump_pr_number: ${{ steps.create-release.outputs.bump_pr_number }} + bump_pr_url: ${{ steps.create-release.outputs.bump_pr_url }} steps: - name: Check test results if: ${{ inputs.dry_run != true && inputs.skip_tests != true && inputs.release_type != 'retag' && needs.pre-release-tests.result != 'success' }} @@ -201,7 +246,7 @@ jobs: cd java-profiler git config --global user.email "java-profiler@datadoghq.com" git config --global user.name "Datadog Java Profiler" - git checkout $GITHUB_REF_NAME + git checkout -B "$GITHUB_REF_NAME" "$GITHUB_SHA" - name: Setup Java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 @@ -209,19 +254,19 @@ jobs: distribution: 'zulu' java-version: '21' - - name: Federate bump-PR token - if: ${{ inputs.dry_run != true }} + - name: Federate release bump label token + id: bump-label-token + if: ${{ inputs.dry_run != true && inputs.release_type != 'retag' }} uses: DataDog/dd-octo-sts-action@96a25462dbcb10ebf0bfd6e2ccc917d2ab235b9a # v1.0.4 - id: octo-sts with: scope: DataDog/java-profiler - policy: self.release-bump.create-pr + policy: self.release-bump.label-pr - name: Create release id: create-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUMP_PR_TOKEN: ${{ steps.octo-sts.outputs.token }} + BUMP_LABEL_TOKEN: ${{ steps.bump-label-token.outputs.token }} run: | cd java-profiler @@ -234,10 +279,20 @@ jobs: TYPE="${{ inputs.release_type }}" ./.github/scripts/release.sh ${TYPE^^} $DRY_RUN + - name: Wait for exact bump approval and selected CI, then merge + if: ${{ inputs.dry_run != true && inputs.release_type != 'retag' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd java-profiler + ./.github/scripts/wait-release-bump.sh \ + --repo "$GITHUB_REPOSITORY" \ + --pr-number '${{ steps.create-release.outputs.bump_pr_number }}' \ + --expected-head-sha '${{ steps.create-release.outputs.bump_head_sha }}' \ + --check release-bump-ci + - name: Output Release Summary if: ${{ inputs.dry_run != true }} - env: - BUMP_PR_URL: ${{ steps.create-release.outputs.BUMP_PR_URL }} run: | echo "## ✅ Release Created Successfully" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -245,10 +300,9 @@ jobs: echo "- **Tag**: v_${{ needs.validate-inputs.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY echo "- **Branch**: ${{ needs.validate-inputs.outputs.release_branch }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -n "$BUMP_PR_URL" ]; then - echo "### ⚠ Action Required" >> $GITHUB_STEP_SUMMARY - echo "A version bump PR needs review and merge before the next snapshot builds carry the correct version:" >> $GITHUB_STEP_SUMMARY - echo "- $BUMP_PR_URL" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.release_type }}" != "retag" ]; then + echo "### Version Bump" >> $GITHUB_STEP_SUMMARY + echo "- **Automatically merged PR**: ${{ steps.create-release.outputs.bump_pr_url }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY fi echo "### Next Steps (Automatic)" >> $GITHUB_STEP_SUMMARY diff --git a/utils/README.md b/utils/README.md index 5353920334..112176a3a0 100644 --- a/utils/README.md +++ b/utils/README.md @@ -1,5 +1,7 @@ # Utility Scripts + + This directory contains utility scripts for managing the java-profiler project. --- @@ -12,7 +14,9 @@ Triggers the Validated Release workflow using GitHub CLI to create a new release **Prerequisites:** - [GitHub CLI](https://cli.github.com/) installed and authenticated +- [jq](https://jqlang.github.io/jq/) installed - Git repository is up to date +- The authenticated user has write, maintain, or admin repository access - You are on the correct branch for the release type **Usage:** @@ -38,10 +42,47 @@ Triggers the Validated Release workflow using GitHub CLI to create a new release 1. Validates inputs and branch rules 2. Interactive commit selection (or use `--commit`) 3. Triggers GitHub Actions "Validated Release" workflow -4. Workflow runs pre-release tests, creates annotated git tag -5. Tag push triggers GitLab build pipeline -6. GitLab builds multi-platform artifacts and publishes to Maven Central -7. GitHub workflows create release with assets +4. Workflow runs pre-release tests, creates the annotated tag, and opens an + exact single-commit version-bump PR as `github-actions[bot]` +5. The final commit is pushed through the release SSH identity, producing the + `synchronize` event that starts normal PR CI even though `GITHUB_TOKEN` + created the PR +6. A separate `dd-octo-sts[bot]` identity adds `trivial`; the approval workflow + validates permissions, refs, SHAs, and the exact one-line version diff before + approving that exact commit +7. The release workflow waits for the exact approval and the aggregate + `release-bump-ci` check, then performs the SHA-locked squash merge itself +8. Tag push triggers GitLab, which publishes the Maven artifacts, and the + GitHub release workflows attach the release assets + +For a major release, the generated `N.0.0` commit remains on +`release/N.0._` and is tagged there. The bump PR moves `main` directly from +its recorded source commit to `N.1.0`; the workflow never pushes a generated +commit directly to protected `main`. + +A new release branch initially remains at its tagged `X.Y.0` minor version. +The first patch creates and tags an `X.Y.1` release commit, then opens the +validated bump PR for `X.Y.2-SNAPSHOT`. Later patches release the untagged +development version left by the preceding bump PR. An already-tagged patch +version greater than zero is rejected because it means that preceding bump PR +did not merge. + +The repository's Actions settings must allow GitHub Actions to create and +approve pull requests. A dry run never creates a PR, adds a label, requests +approval, or merges anything. + +### Testing release automation + +`.github/scripts/tests/test_release_automation.sh` is a single hermetic shell +test. It validates success, +authorization failures, fork/bot PRs, malformed or extra diffs, version +rollovers, merge commits, and stale SHAs using temporary local fixtures. Its +fixture mode does not load credentials or invoke `gh`, so it cannot publish, +tag, push, create a PR, approve, or merge anything remotely. + +```bash +.github/scripts/tests/test_release_automation.sh +``` --- diff --git a/utils/release.sh b/utils/release.sh index 1c2be09fc7..96f371ea91 100755 --- a/utils/release.sh +++ b/utils/release.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2025, Datadog, Inc +# Copyright 2026, Datadog, Inc # Script to trigger the Validated Release workflow using GitHub CLI # @@ -105,7 +105,7 @@ select_release_branch() { echo "" >&2 for i in "${!branches[@]}"; do - if [ $i -eq $selected ]; then + if [ "$i" -eq "$selected" ]; then echo -e "${GREEN}→ ${branches[$i]}${NC}" >&2 else echo -e " ${branches[$i]}" >&2 @@ -176,7 +176,7 @@ select_commit() { IFS='|' read -r sha date author message <<< "${commits[$i]}" local short_sha="${sha:0:8}" - if [ $i -eq $selected ]; then + if [ "$i" -eq "$selected" ]; then echo -e "${GREEN}→ ${short_sha}${NC} ${YELLOW}${date}${NC} ${BLUE}${author:0:20}${NC} ${message:0:60}" >&2 else echo -e " ${short_sha} ${date} ${author:0:20} ${message:0:60}" >&2 @@ -444,6 +444,18 @@ if ! echo "$AUTH_STATUS" | grep -q "Logged in"; then fi print_info "GitHub authentication verified" +REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') +REPO_URL=$(gh repo view --json url --jq '.url') +VIEWER_PERMISSION=$(gh repo view --json viewerPermission --jq '.viewerPermission') +case "$VIEWER_PERMISSION" in + WRITE|MAINTAIN|ADMIN) ;; + *) + print_error "Release execution requires write, maintain, or admin access to $REPO" + exit 1 + ;; +esac +ACTOR=$(gh api user --jq '.login') + # Branch validation already done earlier (before commit selection) # Show summary @@ -478,6 +490,7 @@ fi echo "" print_info "Triggering GitHub Actions workflow..." +REQUEST_ID="release-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM" # Trigger the workflow WORKFLOW_OUTPUT=$(mktemp) @@ -487,19 +500,29 @@ if gh workflow run release-validated.yml \ --ref "$BRANCH" \ --field release_type="$RELEASE_TYPE" \ --field dry_run="$DRY_RUN" \ - --field skip_tests="$SKIP_TESTS" > "$WORKFLOW_OUTPUT" 2> "$WORKFLOW_ERROR"; then + --field skip_tests="$SKIP_TESTS" \ + --field request_id="$REQUEST_ID" \ + --field source_sha="$COMMIT_SHA" > "$WORKFLOW_OUTPUT" 2> "$WORKFLOW_ERROR"; then WORKFLOW_SUCCESS=true echo "" print_success "✓ Workflow triggered successfully!" - REPO_URL=$(gh repo view --json url -q .url) - # Wait for the run to appear and capture its ID + # Correlate by an unguessable request ID plus actor, branch, and exact source + # commit. Never select the merely "latest" release workflow run. print_info "Waiting for workflow run to appear..." RUN_ID="" for i in $(seq 1 15); do sleep 2 - RUN_ID=$(gh run list --workflow=release-validated.yml --limit 1 --json databaseId,status -q '.[0].databaseId // empty') + RUN_ID=$(gh api "repos/$REPO/actions/runs?event=workflow_dispatch&per_page=50" \ + --jq ".workflow_runs + | map(select( + (.display_title | contains(\"$REQUEST_ID\")) and + .actor.login == \"$ACTOR\" and + .head_branch == \"$BRANCH\" and + .head_sha == \"$COMMIT_SHA\" + )) + | if length == 1 then .[0].id else empty end") if [ -n "$RUN_ID" ]; then break fi @@ -593,7 +616,7 @@ else echo "" echo "Error Details:" if [ -s "$WORKFLOW_ERROR" ]; then - cat "$WORKFLOW_ERROR" | sed 's/^/ /' + sed 's/^/ /' "$WORKFLOW_ERROR" else echo " Unknown error. Check GitHub CLI authentication and repository access." fi @@ -611,7 +634,8 @@ print_info "══════════════════════ rm -f "$WORKFLOW_OUTPUT" "$WORKFLOW_ERROR" # Exit with appropriate code -if [ "$WORKFLOW_SUCCESS" = true ]; then +if [ "$WORKFLOW_SUCCESS" = true ] && + [ "${WORKFLOW_CONCLUSION:-unknown}" = "success" ]; then exit 0 else exit 1