From 3d60ccf972e9debe3790afa6acfc33e589d26393 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 11:25:58 +1200 Subject: [PATCH 01/32] [ML] Automate patch version bump in CI pipeline Replace the version-bump pipeline stub with a patch-only flow: Slack notification, Wolfi step running dev-tools/bump_version.sh to bump elasticsearchVersion on BRANCH (and .backportrc.json on main), then json-watcher polling for staging and snapshot artifact versions. Supports DRY_RUN=true to skip git push. Minor-branch automation will follow in a separate change. Made-with: Cursor --- .buildkite/job-version-bump.json.py | 117 ++++++++++++++------------ dev-tools/bump_version.sh | 124 ++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 52 deletions(-) create mode 100755 dev-tools/bump_version.sh diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 61f763987..138ba1ac8 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -10,83 +10,96 @@ # # This script generates JSON for the ml-cpp version bump pipeline. # It is intended to be triggered by the centralized release-eng pipeline. -# It can be integrated into existing or new workflows and includes a plugin -# that polls artifact URLs until the expected version is available. +# +# Patch workflow: bump version on BRANCH, then wait for staging and snapshot +# artifact JSON to publish NEW_VERSION. import contextlib import json +WOLFI_IMAGE = "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest" +STAGING_URL = "https://artifacts-staging.elastic.co/ml-cpp/latest" +SNAPSHOT_URL = "https://storage.googleapis.com/elastic-artifacts-snapshot/ml-cpp/latest" + + +def json_watcher_plugin(url, expected_value): + return { + "elastic/json-watcher#v1.0.0": { + "url": url, + "field": ".version", + "expected_value": expected_value, + "polling_interval": "30", + } + } + + +def dra_step(label, key, depends_on, plugins): + return { + "label": label, + "key": key, + "depends_on": depends_on, + "agents": { + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", + "ephemeralStorage": "1Gi", + }, + "command": [ + 'echo "Waiting for DRA artifacts..."', + ], + "timeout_in_minutes": 240, + "retry": { + "automatic": [{"exit_status": "*", "limit": 2}], + "manual": {"permit_on_passed": True}, + }, + "plugins": plugins, + } + + def main(): - pipeline = {} - # TODO: replace the block step with version bump logic pipeline_steps = [ { "label": "Queue a :slack: notification for the pipeline", "depends_on": None, - "command": ".buildkite/pipelines/send_version_bump_notification.sh | buildkite-agent pipeline upload", + "command": ".buildkite/pipelines/send_slack_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", }, }, { - "block": "Ready to fetch for DRA artifacts?", - "prompt": ( - "Unblock when your team is ready to proceed.\n\n" - "Trigger parameters:\n" - "- NEW_VERSION: ${NEW_VERSION}\n" - "- BRANCH: ${BRANCH}\n" - "- WORKFLOW: ${WORKFLOW}\n" - ), - "key": "block-get-dra-artifacts", - "blocked_state": "running", - }, - { - "label": "Fetch DRA Artifacts", - "key": "fetch-dra-artifacts", - "depends_on": "block-get-dra-artifacts", + "label": "Bump version to ${NEW_VERSION}", + "key": "bump-version", "agents": { - "image": "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest", + "image": WOLFI_IMAGE, "cpu": "250m", "memory": "512Mi", - "ephemeralStorage": "1Gi", }, "command": [ - 'echo "Starting DRA artifacts retrieval..."', - ], - "timeout_in_minutes": 240, - "retry": { - "automatic": [ - { - "exit_status": "*", - "limit": 2, - } - ], - "manual": {"permit_on_passed": True}, - }, - "plugins": [ - { - "elastic/json-watcher#v1.0.0": { - "url": "https://artifacts-staging.elastic.co/ml-cpp/latest/${BRANCH}.json", - "field": ".version", - "expected_value": "${NEW_VERSION}", - "polling_interval": "30", - } - }, - { - "elastic/json-watcher#v1.0.0": { - "url": "https://storage.googleapis.com/elastic-artifacts-snapshot/ml-cpp/latest/${BRANCH}.json", - "field": ".version", - "expected_value": "${NEW_VERSION}-SNAPSHOT", - "polling_interval": "30", - } - }, + "dev-tools/bump_version.sh", ], }, + dra_step( + label="Fetch DRA Artifacts", + key="fetch-dra-artifacts", + depends_on="bump-version", + plugins=[ + json_watcher_plugin( + f"{STAGING_URL}/${{BRANCH}}.json", + "${NEW_VERSION}", + ), + json_watcher_plugin( + f"{SNAPSHOT_URL}/${{BRANCH}}.json", + "${NEW_VERSION}-SNAPSHOT", + ), + ], + ), ] - pipeline["steps"] = pipeline_steps + pipeline = { + "steps": pipeline_steps, + } print(json.dumps(pipeline, indent=2)) diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh new file mode 100755 index 000000000..625338b4f --- /dev/null +++ b/dev-tools/bump_version.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Automated patch version bump for the release-eng pipeline. +# +# Updates elasticsearchVersion in gradle.properties to NEW_VERSION on BRANCH, +# updates .backportrc.json when bumping main, commits, and pushes. +# +# Set DRY_RUN=true to perform all steps except git push. +# +# Follows the same pattern as the Elasticsearch repo's automated +# Lucene snapshot updates (.buildkite/scripts/lucene-snapshot/). + +set -euo pipefail + +: "${NEW_VERSION:?NEW_VERSION must be set}" +: "${BRANCH:?BRANCH must be set}" +DRY_RUN="${DRY_RUN:-false}" + +GRADLE_PROPS="gradle.properties" +BACKPORT_CONFIG=".backportrc.json" + +if [ "$DRY_RUN" = "true" ]; then + echo "=== DRY RUN MODE — will not push ===" +fi + +git_push() { + local target_branch="$1" + if [ "$DRY_RUN" = "true" ]; then + echo " [DRY RUN] Would push $target_branch" + else + git push origin "$target_branch" + echo " Pushed $target_branch" + fi +} + +sed_inplace() { + if sed --version >/dev/null 2>&1; then + sed -i "$@" + else + sed -i '' "$@" + fi +} + +configure_git() { + git config user.name elasticsearchmachine + git config user.email 'infra-root+elasticsearchmachine@elastic.co' +} + +bump_version_on_branch() { + local target_branch="$1" + local target_version="$2" + + git checkout "$target_branch" + git pull --ff-only origin "$target_branch" + + local current_version + current_version=$(grep '^elasticsearchVersion=' "$GRADLE_PROPS" | cut -d= -f2) + if [ "$current_version" = "$target_version" ]; then + echo "Version on $target_branch is already $target_version — nothing to do" + return 0 + fi + + echo "Bumping version on $target_branch: $current_version → $target_version" + sed_inplace "s/^elasticsearchVersion=.*/elasticsearchVersion=${target_version}/" "$GRADLE_PROPS" + + if ! grep -q "^elasticsearchVersion=${target_version}$" "$GRADLE_PROPS"; then + echo "ERROR: version update verification failed on $target_branch" + grep 'elasticsearchVersion' "$GRADLE_PROPS" + exit 1 + fi + + # Update .backportrc.json so the new version maps to main + if [[ "$target_branch" == "main" && -f "$BACKPORT_CONFIG" ]]; then + echo "Updating backport config: v${target_version} → main" + # Use python for a reliable cross-platform JSON-safe replacement + python3 -c " +import json, re, sys +with open('$BACKPORT_CONFIG') as f: + data = json.load(f) +mapping = data.get('branchLabelMapping', {}) +new_mapping = {} +for k, v in mapping.items(): + if v == 'main' and re.match(r'\^v\d+\.\d+\.\d+\\\$', k): + new_mapping['^v${target_version}\$'] = 'main' + else: + new_mapping[k] = v +data['branchLabelMapping'] = new_mapping +with open('$BACKPORT_CONFIG', 'w') as f: + json.dump(data, f, indent=2) + f.write('\n') +" || echo "WARNING: could not update backport config — please check $BACKPORT_CONFIG manually" + fi + + if git diff-index --quiet HEAD --; then + echo "No changes to commit on $target_branch (file unchanged after sed)" + return 0 + fi + + configure_git + git add "$GRADLE_PROPS" "$BACKPORT_CONFIG" + git commit -m "[ML] Bump version to ${target_version}" + git_push "$target_branch" +} + +echo "=== Patch version bump: $BRANCH → $NEW_VERSION ===" +bump_version_on_branch "$BRANCH" "$NEW_VERSION" + +if [ "$DRY_RUN" = "true" ]; then + echo "" + echo "=== DRY RUN SUMMARY ===" + echo "Branch: $BRANCH" + echo "Version: $NEW_VERSION" + echo "Recent commits:" + git log --oneline -3 +fi From fc81aef8c4ecae074ea2c23fa10d289f3b49de63 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 11:54:03 +1200 Subject: [PATCH 02/32] [ML] Version bump Slack: test banner + optional skip DRA wait - Add send_slack_version_bump_notification.sh (version-bump pipeline only) with ML_CPP_VERSION_BUMP_TEST_MODE banner and optional channel override. - Wire job-version-bump.json.py to use it; support ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT for short smoke runs without json-watcher polling. Made-with: Cursor --- .buildkite/job-version-bump.json.py | 40 +++++++++------ .../send_slack_version_bump_notification.sh | 50 +++++++++++++++++++ 2 files changed, 74 insertions(+), 16 deletions(-) create mode 100755 .buildkite/pipelines/send_slack_version_bump_notification.sh diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 138ba1ac8..9daedc53f 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -17,6 +17,7 @@ import contextlib import json +import os WOLFI_IMAGE = "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest" @@ -63,7 +64,7 @@ def main(): { "label": "Queue a :slack: notification for the pipeline", "depends_on": None, - "command": ".buildkite/pipelines/send_slack_notification.sh | buildkite-agent pipeline upload", + "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", }, @@ -80,23 +81,30 @@ def main(): "dev-tools/bump_version.sh", ], }, - dra_step( - label="Fetch DRA Artifacts", - key="fetch-dra-artifacts", - depends_on="bump-version", - plugins=[ - json_watcher_plugin( - f"{STAGING_URL}/${{BRANCH}}.json", - "${NEW_VERSION}", - ), - json_watcher_plugin( - f"{SNAPSHOT_URL}/${{BRANCH}}.json", - "${NEW_VERSION}-SNAPSHOT", - ), - ], - ), ] + # Smoke tests: set ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT on the Buildkite build + # to skip json-watcher polling (avoids a long-running build when NEW_VERSION + # will never appear in artifact JSON). + if not os.environ.get("ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT", "").strip(): + pipeline_steps.append( + dra_step( + label="Fetch DRA Artifacts", + key="fetch-dra-artifacts", + depends_on="bump-version", + plugins=[ + json_watcher_plugin( + f"{STAGING_URL}/${{BRANCH}}.json", + "${NEW_VERSION}", + ), + json_watcher_plugin( + f"{SNAPSHOT_URL}/${{BRANCH}}.json", + "${NEW_VERSION}-SNAPSHOT", + ), + ], + ) + ) + pipeline = { "steps": pipeline_steps, } diff --git a/.buildkite/pipelines/send_slack_version_bump_notification.sh b/.buildkite/pipelines/send_slack_version_bump_notification.sh new file mode 100755 index 000000000..e0f094f84 --- /dev/null +++ b/.buildkite/pipelines/send_slack_version_bump_notification.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Slack notifications for the ml-cpp-version-bump pipeline only (not PR builds). +# Set ML_CPP_VERSION_BUMP_TEST_MODE to any non-empty value to prepend a loud +# "TEST RUN" banner and optional custom channel (see below). +# +# Optional env: +# ML_CPP_VERSION_BUMP_TEST_MODE — non-empty => test banner + wording +# ML_CPP_VERSION_BUMP_SLACK_CHANNEL — override channel (default #machine-learn-build) + +CHANNEL="${ML_CPP_VERSION_BUMP_SLACK_CHANNEL:-#machine-learn-build}" + +if [ -n "${ML_CPP_VERSION_BUMP_TEST_MODE:-}" ]; then + TEST_LINES=' :rotating_light: **TEST RUN — ml-cpp version bump pipeline** :rotating_light: + _This is not a production release._ (ML_CPP_VERSION_BUMP_TEST_MODE is set on the build.) + Set ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT on the build to skip artifact polling for short smoke tests. + +' +else + TEST_LINES="" +fi + +cat < Date: Fri, 1 May 2026 12:28:34 +1200 Subject: [PATCH 03/32] [ML] TEMP: allow version-bump pipeline on PR test branch Widen ml-cpp-version-bump branch_configuration for PR #3030 smoke tests; revert to main-only after validation. Made-with: Cursor --- catalog-info.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/catalog-info.yaml b/catalog-info.yaml index d472b4eee..22e5a837c 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -254,7 +254,9 @@ spec: description: Buildkite Pipeline for ml-cpp version bump spec: allow_rebuilds: true - branch_configuration: main + # TEMPORARY (PR #3030): allow Buildkite API/UI test runs from this branch. + # Revert to `main` only after the version-bump smoke test is complete. + branch_configuration: main feature/version-bump-patch-only cancel_intermediate_builds: false clone_method: https pipeline_file: .buildkite/job-version-bump.json.py From d337984487e38af9e1bc1c80e0c5c203d7d5a886 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 12:31:25 +1200 Subject: [PATCH 04/32] [ML] Remove temp version-bump branch filter from patch bump PR The temporary ml-cpp-version-bump branch_configuration change is proposed separately so PR #3030 stays focused on the bump automation. Made-with: Cursor --- catalog-info.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/catalog-info.yaml b/catalog-info.yaml index 22e5a837c..d472b4eee 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -254,9 +254,7 @@ spec: description: Buildkite Pipeline for ml-cpp version bump spec: allow_rebuilds: true - # TEMPORARY (PR #3030): allow Buildkite API/UI test runs from this branch. - # Revert to `main` only after the version-bump smoke test is complete. - branch_configuration: main feature/version-bump-patch-only + branch_configuration: main cancel_intermediate_builds: false clone_method: https pipeline_file: .buildkite/job-version-bump.json.py From f133adca36395875ebbc6cd24ec85e2c77782dc6 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 14:10:48 +1200 Subject: [PATCH 05/32] [ML] Trim bump_version.sh: drop .backportrc.json handling Patch-only version bumps update gradle.properties only; main/minor backport mapping stays out of this PR. Made-with: Cursor --- dev-tools/bump_version.sh | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 625338b4f..e8cbc7696 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -12,7 +12,8 @@ # Automated patch version bump for the release-eng pipeline. # # Updates elasticsearchVersion in gradle.properties to NEW_VERSION on BRANCH, -# updates .backportrc.json when bumping main, commits, and pushes. +# commits, and pushes. Does not modify .backportrc.json (reserved for a future +# main / minor bump automation change). # # Set DRY_RUN=true to perform all steps except git push. # @@ -26,7 +27,6 @@ set -euo pipefail DRY_RUN="${DRY_RUN:-false}" GRADLE_PROPS="gradle.properties" -BACKPORT_CONFIG=".backportrc.json" if [ "$DRY_RUN" = "true" ]; then echo "=== DRY RUN MODE — will not push ===" @@ -78,35 +78,13 @@ bump_version_on_branch() { exit 1 fi - # Update .backportrc.json so the new version maps to main - if [[ "$target_branch" == "main" && -f "$BACKPORT_CONFIG" ]]; then - echo "Updating backport config: v${target_version} → main" - # Use python for a reliable cross-platform JSON-safe replacement - python3 -c " -import json, re, sys -with open('$BACKPORT_CONFIG') as f: - data = json.load(f) -mapping = data.get('branchLabelMapping', {}) -new_mapping = {} -for k, v in mapping.items(): - if v == 'main' and re.match(r'\^v\d+\.\d+\.\d+\\\$', k): - new_mapping['^v${target_version}\$'] = 'main' - else: - new_mapping[k] = v -data['branchLabelMapping'] = new_mapping -with open('$BACKPORT_CONFIG', 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') -" || echo "WARNING: could not update backport config — please check $BACKPORT_CONFIG manually" - fi - if git diff-index --quiet HEAD --; then echo "No changes to commit on $target_branch (file unchanged after sed)" return 0 fi configure_git - git add "$GRADLE_PROPS" "$BACKPORT_CONFIG" + git add "$GRADLE_PROPS" git commit -m "[ML] Bump version to ${target_version}" git_push "$target_branch" } From 00b04b64aa3c64b31768ce741b7fdd4909a34507 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 14:13:35 +1200 Subject: [PATCH 06/32] [ML] Trim version-bump smoke hooks; keep DRY_RUN Remove ML_CPP_VERSION_BUMP_TEST_MODE / TEST banner and ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT (DRA wait steps always run). DRY_RUN remains a normal Buildkite env for bump_version.sh. Optional ML_CPP_VERSION_BUMP_SLACK_CHANNEL retained for routing. Made-with: Cursor --- .buildkite/job-version-bump.json.py | 37 ++++++++----------- .../send_slack_version_bump_notification.sh | 15 +------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 9daedc53f..07284b55f 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -81,30 +81,23 @@ def main(): "dev-tools/bump_version.sh", ], }, + dra_step( + label="Fetch DRA Artifacts", + key="fetch-dra-artifacts", + depends_on="bump-version", + plugins=[ + json_watcher_plugin( + f"{STAGING_URL}/${{BRANCH}}.json", + "${NEW_VERSION}", + ), + json_watcher_plugin( + f"{SNAPSHOT_URL}/${{BRANCH}}.json", + "${NEW_VERSION}-SNAPSHOT", + ), + ], + ), ] - # Smoke tests: set ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT on the Buildkite build - # to skip json-watcher polling (avoids a long-running build when NEW_VERSION - # will never appear in artifact JSON). - if not os.environ.get("ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT", "").strip(): - pipeline_steps.append( - dra_step( - label="Fetch DRA Artifacts", - key="fetch-dra-artifacts", - depends_on="bump-version", - plugins=[ - json_watcher_plugin( - f"{STAGING_URL}/${{BRANCH}}.json", - "${NEW_VERSION}", - ), - json_watcher_plugin( - f"{SNAPSHOT_URL}/${{BRANCH}}.json", - "${NEW_VERSION}-SNAPSHOT", - ), - ], - ) - ) - pipeline = { "steps": pipeline_steps, } diff --git a/.buildkite/pipelines/send_slack_version_bump_notification.sh b/.buildkite/pipelines/send_slack_version_bump_notification.sh index e0f094f84..3e99d0c97 100755 --- a/.buildkite/pipelines/send_slack_version_bump_notification.sh +++ b/.buildkite/pipelines/send_slack_version_bump_notification.sh @@ -9,25 +9,12 @@ # limitation. # # Slack notifications for the ml-cpp-version-bump pipeline only (not PR builds). -# Set ML_CPP_VERSION_BUMP_TEST_MODE to any non-empty value to prepend a loud -# "TEST RUN" banner and optional custom channel (see below). # # Optional env: -# ML_CPP_VERSION_BUMP_TEST_MODE — non-empty => test banner + wording # ML_CPP_VERSION_BUMP_SLACK_CHANNEL — override channel (default #machine-learn-build) CHANNEL="${ML_CPP_VERSION_BUMP_SLACK_CHANNEL:-#machine-learn-build}" -if [ -n "${ML_CPP_VERSION_BUMP_TEST_MODE:-}" ]; then - TEST_LINES=' :rotating_light: **TEST RUN — ml-cpp version bump pipeline** :rotating_light: - _This is not a production release._ (ML_CPP_VERSION_BUMP_TEST_MODE is set on the build.) - Set ML_CPP_VERSION_BUMP_SKIP_DRA_WAIT on the build to skip artifact polling for short smoke tests. - -' -else - TEST_LINES="" -fi - cat < Date: Fri, 1 May 2026 14:20:09 +1200 Subject: [PATCH 07/32] [ML] Add git push --dry-run auth probe for version bump pipeline Runs before Slack queue and bump so CI credentials are validated without creating or updating remote refs. Made-with: Cursor --- .buildkite/job-version-bump.json.py | 21 ++++++++++++--- dev-tools/git_push_auth_probe.sh | 41 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100755 dev-tools/git_push_auth_probe.sh diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 07284b55f..5d5d17fef 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -11,8 +11,8 @@ # This script generates JSON for the ml-cpp version bump pipeline. # It is intended to be triggered by the centralized release-eng pipeline. # -# Patch workflow: bump version on BRANCH, then wait for staging and snapshot -# artifact JSON to publish NEW_VERSION. +# Patch workflow: verify git push credentials (dry-run), bump version on BRANCH, +# then wait for staging and snapshot artifact JSON to publish NEW_VERSION. import contextlib @@ -62,8 +62,22 @@ def dra_step(label, key, depends_on, plugins): def main(): pipeline_steps = [ { - "label": "Queue a :slack: notification for the pipeline", + "label": "Verify git push credentials (dry-run)", + "key": "git-push-auth-probe", "depends_on": None, + "agents": { + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", + }, + "command": [ + "dev-tools/git_push_auth_probe.sh", + ], + }, + { + "label": "Queue a :slack: notification for the pipeline", + "key": "queue-slack-notify", + "depends_on": "git-push-auth-probe", "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", @@ -72,6 +86,7 @@ def main(): { "label": "Bump version to ${NEW_VERSION}", "key": "bump-version", + "depends_on": "queue-slack-notify", "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh new file mode 100755 index 000000000..7059d3fc2 --- /dev/null +++ b/dev-tools/git_push_auth_probe.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Verifies that the current checkout can authenticate for git push to origin, +# without updating any remote refs (uses "git push --dry-run"). +# +# Intended for the ml-cpp-version-bump Buildkite pipeline (same agent + remotes +# as dev-tools/bump_version.sh). Uses a disposable ref name under refs/heads/ci/ +# so it does not collide with release branches. +# +# Environment: +# BUILDKITE_BUILD_NUMBER — used to uniquify the probe ref (defaults to "local" +# when unset, e.g. manual runs outside Buildkite). +# GIT_REMOTE — remote name (default: origin). + +set -euo pipefail + +REMOTE="${GIT_REMOTE:-origin}" +BUILD_NUM="${BUILDKITE_BUILD_NUMBER:-local}" +PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" + +echo "=== Git push auth probe (dry-run; no remote refs updated) ===" +echo "Remote: ${REMOTE}" +echo "Local HEAD: $(git rev-parse HEAD)" +echo "Probe refspec: HEAD:${PROBE_REF}" +git remote -v + +if ! git push --dry-run "${REMOTE}" "HEAD:${PROBE_REF}"; then + echo "ERROR: git push --dry-run failed — check credentials and GitHub permissions for ${REMOTE}." >&2 + exit 1 +fi + +echo "OK: git push --dry-run succeeded for ${REMOTE}." From 9eb99eef3e7f25e7c71fa12fa207e4acf98d9d30 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Fri, 1 May 2026 15:21:05 +1200 Subject: [PATCH 08/32] [ML] Add skeleton GHA workflow for patch version bump workflow_dispatch with branch/new_version/dry_run; GitHub App token for checkout and git push (addresses Buildkite Vault bot 403 class of issues). Document suggested secrets in workflow header. Made-with: Cursor --- .github/workflows/run-patch-release.yml | 115 ++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/run-patch-release.yml diff --git a/.github/workflows/run-patch-release.yml b/.github/workflows/run-patch-release.yml new file mode 100644 index 000000000..a600f2aaf --- /dev/null +++ b/.github/workflows/run-patch-release.yml @@ -0,0 +1,115 @@ +# Skeleton: patch-oriented version bump via GitHub Actions (APM-style entry point). +# Buildkite (or humans) can trigger this with `gh workflow run` once secrets exist. +# +# Required repository secrets (names are suggestions — align with infra / reuse an +# existing org GitHub App after review): +# ML_CPP_RELEASE_GITHUB_APP_ID +# ML_CPP_RELEASE_GITHUB_APP_PRIVATE_KEY (PEM for the app; multiline secret) +# +# Optional (Slack — mirror elastic/apm-server when ready): +# SLACK_BOT_TOKEN +# +# Install the GitHub App on elastic/ml-cpp with contents:write + pull-requests:write +# (same class of permissions as elastic/apm-server run-patch-release.yml). + +name: ml-cpp run patch release + +on: + workflow_dispatch: + inputs: + branch: + description: "Git branch to bump (e.g. 9.5 or feature/foo — must exist on origin)" + required: true + type: string + new_version: + description: "Target elasticsearchVersion in gradle.properties (x.y.z)" + required: true + type: string + dry_run: + description: "If true, bump_version.sh runs with DRY_RUN=true (no git push)" + required: false + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.run_id }} + +permissions: + contents: read + +jobs: + prepare: + name: Prepare (validate inputs) + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.meta.outputs.branch }} + new_version: ${{ steps.meta.outputs.new_version }} + dry_run: ${{ steps.meta.outputs.dry_run }} + steps: + - name: Validate and emit outputs + id: meta + shell: bash + env: + BRANCH_IN: ${{ inputs.branch }} + VERSION_IN: ${{ inputs.new_version }} + DRY_IN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + if [[ -z "${BRANCH_IN// }" ]]; then + echo "::error::branch must not be empty" + exit 1 + fi + if ! echo "${VERSION_IN}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::new_version must look like major.minor.patch (digits only)" + exit 1 + fi + { + echo "branch=${BRANCH_IN}" + echo "new_version=${VERSION_IN}" + } >> "${GITHUB_OUTPUT}" + if [[ "${DRY_IN}" == "true" ]]; then + echo "dry_run=true" >> "${GITHUB_OUTPUT}" + else + echo "dry_run=false" >> "${GITHUB_OUTPUT}" + fi + + bump: + name: Bump version (GitHub App token) + runs-on: ubuntu-latest + needs: + - prepare + steps: + - name: Mint installation token (GitHub App) + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ML_CPP_RELEASE_GITHUB_APP_ID }} + private-key: ${{ secrets.ML_CPP_RELEASE_GITHUB_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Checkout target branch + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ needs.prepare.outputs.branch }} + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + # Match dev-tools/bump_version.sh configure_git() so commits stay consistent. + # Swap for elastic/oblt-actions/git/setup@v1 if infra prefers the shared action. + - name: Configure git (service identity) + shell: bash + run: | + git config user.name elasticsearchmachine + git config user.email 'infra-root+elasticsearchmachine@elastic.co' + + - name: Run bump script + env: + NEW_VERSION: ${{ needs.prepare.outputs.new_version }} + BRANCH: ${{ needs.prepare.outputs.branch }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} + shell: bash + run: | + set -euo pipefail + bash dev-tools/bump_version.sh From 003c4c11009d2eb50c5666584d8015790baab208 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Mon, 4 May 2026 10:45:41 +1200 Subject: [PATCH 09/32] [ML] TEMP: log Vault GitHub app permissions in bump probe Run gh api (or curl fallback) against elastic-vault-github-plugin-prod during ml-cpp-version-bump git push auth probe; non-fatal. Remove before merge. Co-authored-by: Cursor --- dev-tools/git_push_auth_probe.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh index 7059d3fc2..447ef093a 100755 --- a/dev-tools/git_push_auth_probe.sh +++ b/dev-tools/git_push_auth_probe.sh @@ -27,6 +27,24 @@ REMOTE="${GIT_REMOTE:-origin}" BUILD_NUM="${BUILDKITE_BUILD_NUMBER:-local}" PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" +# TEMPORARY (version-bump PR): inspect GitHub App metadata; remove before merge. +echo "=== TEMPORARY: elastic-vault-github-plugin-prod — reported .permissions ===" +if [ -n "${VAULT_GITHUB_TOKEN:-}" ]; then + if command -v gh >/dev/null 2>&1; then + GH_TOKEN="${VAULT_GITHUB_TOKEN}" gh api /apps/elastic-vault-github-plugin-prod --jq '.permissions' || + echo "WARNING: gh api app permissions query failed (non-fatal)." >&2 + else + echo "NOTE: gh not in PATH; using curl for the same REST endpoint." >&2 + curl -sS -H "Authorization: Bearer ${VAULT_GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/apps/elastic-vault-github-plugin-prod" | + python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin).get("permissions"), indent=2))' || + echo "WARNING: curl/python app permissions query failed (non-fatal)." >&2 + fi +else + echo "WARNING: VAULT_GITHUB_TOKEN unset; skipping app permissions diagnostic." >&2 +fi + echo "=== Git push auth probe (dry-run; no remote refs updated) ===" echo "Remote: ${REMOTE}" echo "Local HEAD: $(git rev-parse HEAD)" From 1acb334483fd975a7aa94581c58eeb8cb41d2892 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Mon, 4 May 2026 10:54:46 +1200 Subject: [PATCH 10/32] [ML] Remove temporary GitHub App permissions probe from bump script Co-authored-by: Cursor --- dev-tools/git_push_auth_probe.sh | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh index 447ef093a..7059d3fc2 100755 --- a/dev-tools/git_push_auth_probe.sh +++ b/dev-tools/git_push_auth_probe.sh @@ -27,24 +27,6 @@ REMOTE="${GIT_REMOTE:-origin}" BUILD_NUM="${BUILDKITE_BUILD_NUMBER:-local}" PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" -# TEMPORARY (version-bump PR): inspect GitHub App metadata; remove before merge. -echo "=== TEMPORARY: elastic-vault-github-plugin-prod — reported .permissions ===" -if [ -n "${VAULT_GITHUB_TOKEN:-}" ]; then - if command -v gh >/dev/null 2>&1; then - GH_TOKEN="${VAULT_GITHUB_TOKEN}" gh api /apps/elastic-vault-github-plugin-prod --jq '.permissions' || - echo "WARNING: gh api app permissions query failed (non-fatal)." >&2 - else - echo "NOTE: gh not in PATH; using curl for the same REST endpoint." >&2 - curl -sS -H "Authorization: Bearer ${VAULT_GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/apps/elastic-vault-github-plugin-prod" | - python3 -c 'import json, sys; print(json.dumps(json.load(sys.stdin).get("permissions"), indent=2))' || - echo "WARNING: curl/python app permissions query failed (non-fatal)." >&2 - fi -else - echo "WARNING: VAULT_GITHUB_TOKEN unset; skipping app permissions diagnostic." >&2 -fi - echo "=== Git push auth probe (dry-run; no remote refs updated) ===" echo "Remote: ${REMOTE}" echo "Local HEAD: $(git rev-parse HEAD)" From 8127a6bf64bcee66ee127f017643e7fd63c2e7b3 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Mon, 4 May 2026 14:09:07 +1200 Subject: [PATCH 11/32] [ML] Version bump validation, pytest, and dev-tools CI step - Add version_bump_validation.py and wire validate_version_bump_params.sh to git fetch + Python rules (SKIP before env checks). - Add pytest suite under dev-tools/unittest, pytest.ini, test-requirements.txt, run_dev_tools_tests.sh. - Extend ml-cpp-version-bump pipeline with validate-version-bump step; document bump_version.sh. - Add dev_tools_pytest Buildkite step (python:3) to format_and_validation pipeline. - Add test_validate_version_bump_local.sh helper for manual branch testing. Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 21 +- .../pipelines/format_and_validation.yml.sh | 1 + dev-tools/bump_version.sh | 3 + dev-tools/test_validate_version_bump_local.sh | 188 ++++++++++++++++ .../unittest/test_version_bump_validation.py | 208 ++++++++++++++++++ dev-tools/validate_version_bump_params.sh | 69 ++++++ dev-tools/version_bump_validation.py | 197 +++++++++++++++++ 7 files changed, 684 insertions(+), 3 deletions(-) create mode 100755 dev-tools/test_validate_version_bump_local.sh create mode 100644 dev-tools/unittest/test_version_bump_validation.py create mode 100755 dev-tools/validate_version_bump_params.sh create mode 100644 dev-tools/version_bump_validation.py diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 5d5d17fef..1075841d5 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -11,8 +11,10 @@ # This script generates JSON for the ml-cpp version bump pipeline. # It is intended to be triggered by the centralized release-eng pipeline. # -# Patch workflow: verify git push credentials (dry-run), bump version on BRANCH, -# then wait for staging and snapshot artifact JSON to publish NEW_VERSION. +# Patch workflow: validate NEW_VERSION/BRANCH/WORKFLOW, verify git push +# credentials (dry-run), bump version on BRANCH, then wait for staging and +# snapshot artifact JSON to publish NEW_VERSION. Set WORKFLOW=minor for minor +# bumps; defaults to patch. import contextlib @@ -61,10 +63,23 @@ def dra_step(label, key, depends_on, plugins): def main(): pipeline_steps = [ + { + "label": "Validate version bump parameters", + "key": "validate-version-bump", + "depends_on": None, + "agents": { + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", + }, + "command": [ + "dev-tools/validate_version_bump_params.sh", + ], + }, { "label": "Verify git push credentials (dry-run)", "key": "git-push-auth-probe", - "depends_on": None, + "depends_on": "validate-version-bump", "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/.buildkite/pipelines/format_and_validation.yml.sh b/.buildkite/pipelines/format_and_validation.yml.sh index c6484d9cb..d050c2471 100755 --- a/.buildkite/pipelines/format_and_validation.yml.sh +++ b/.buildkite/pipelines/format_and_validation.yml.sh @@ -18,6 +18,7 @@ steps: notify: - github_commit_status: context: "Validate formatting with clang-format" + - label: "dev-tools pytest" key: "dev_tools_pytest" command: ".buildkite/scripts/steps/dev_tools_pytest.sh" diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index e8cbc7696..59cffb50b 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -11,6 +11,9 @@ # # Automated patch version bump for the release-eng pipeline. # +# Parameter checks (increment rules, BRANCH vs version) run in +# dev-tools/validate_version_bump_params.sh in the ml-cpp-version-bump pipeline. +# # Updates elasticsearchVersion in gradle.properties to NEW_VERSION on BRANCH, # commits, and pushes. Does not modify .backportrc.json (reserved for a future # main / minor bump automation change). diff --git a/dev-tools/test_validate_version_bump_local.sh b/dev-tools/test_validate_version_bump_local.sh new file mode 100755 index 000000000..c3d2a9c6c --- /dev/null +++ b/dev-tools/test_validate_version_bump_local.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Local helper to exercise dev-tools/validate_version_bump_params.sh against +# origin/: fetch the branch, derive NEW_VERSION from the current +# elasticsearchVersion in gradle.properties, export env, run the validator. +# +# Usage: +# ./dev-tools/test_validate_version_bump_local.sh [options] +# +# Options: +# --negative Use an intentionally invalid NEW_VERSION (expects validator failure) +# --workflow patch | minor Override workflow (default: patch for positive, +# minor mode targets MAJOR.(MINOR+1).0 on the next minor line) +# --dry-run Print env and computed versions only; do not run validator +# +# Examples: +# ./dev-tools/test_validate_version_bump_local.sh 9.5 +# ./dev-tools/test_validate_version_bump_local.sh 9.5 --negative +# ./dev-tools/test_validate_version_bump_local.sh 9.5 --workflow minor --dry-run + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VALIDATOR="${SCRIPT_DIR}/validate_version_bump_params.sh" + +usage() { + echo "Usage: $0 [--negative] [--workflow patch|minor] [--dry-run]" >&2 + echo " BRANCH Release branch MAJOR.MINOR (e.g. 9.5), matching origin." >&2 + exit 1 +} + +BRANCH="" +NEGATIVE="false" +WORKFLOW_OVERRIDE="" +DRY_RUN="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --negative) + NEGATIVE="true" + shift + ;; + --workflow) + WORKFLOW_OVERRIDE="${2:?}" + shift 2 + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + -h | --help) + usage + ;; + *) + if [[ -n "$BRANCH" ]]; then + echo "ERROR: unexpected argument: $1" >&2 + usage + fi + BRANCH="$1" + shift + ;; + esac +done + +if [[ -z "$BRANCH" ]]; then + usage +fi + +cd "$REPO_ROOT" + +if [[ ! -x "$VALIDATOR" && ! -f "$VALIDATOR" ]]; then + echo "ERROR: validator not found at ${VALIDATOR}" >&2 + exit 1 +fi + +parse_triple() { + local v="$1" + local re='^([0-9]+)\.([0-9]+)\.([0-9]+)$' + if [[ "$v" =~ $re ]]; then + _M="${BASH_REMATCH[1]}" + _N="${BASH_REMATCH[2]}" + _P="${BASH_REMATCH[3]}" + return 0 + fi + return 1 +} + +echo "=== Fetch origin/${BRANCH} ===" +git fetch origin "$BRANCH" + +CURRENT_VERSION=$( + git show FETCH_HEAD:gradle.properties | grep '^elasticsearchVersion=' | head -1 | cut -d= -f2 | tr -d '[:space:]' +) + +if [[ -z "$CURRENT_VERSION" ]]; then + echo "ERROR: could not read elasticsearchVersion from FETCH_HEAD" >&2 + exit 1 +fi + +if ! parse_triple "$CURRENT_VERSION"; then + echo "ERROR: invalid elasticsearchVersion on branch: '${CURRENT_VERSION}'" >&2 + exit 1 +fi + +MAJOR="$_M" +MINOR="$_N" +PATCH="$_P" + +WORKFLOW="${WORKFLOW_OVERRIDE:-patch}" + +if [[ "$WORKFLOW" != "patch" && "$WORKFLOW" != "minor" ]]; then + echo "ERROR: --workflow must be patch or minor" >&2 + exit 1 +fi + +if [[ "$NEGATIVE" == "true" && "$WORKFLOW" == "minor" ]]; then + echo "ERROR: --negative is only implemented for patch workflow" >&2 + exit 1 +fi + +if [[ "$WORKFLOW" == "patch" ]]; then + if [[ "$BRANCH" != "${MAJOR}.${MINOR}" ]]; then + echo "ERROR: BRANCH '${BRANCH}' must match MAJOR.MINOR of current version (${MAJOR}.${MINOR}) for patch test" >&2 + exit 1 + fi + if [[ "$NEGATIVE" == "true" ]]; then + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 2))" + echo "=== Negative test: invalid patch jump ${CURRENT_VERSION} → ${NEW_VERSION} (expected failure) ===" + else + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + echo "=== Positive patch test: ${CURRENT_VERSION} → ${NEW_VERSION} ===" + fi +else + EXPECT_BRANCH="${MAJOR}.$((MINOR + 1))" + if [[ "$BRANCH" != "$EXPECT_BRANCH" ]]; then + echo "ERROR: for minor workflow, BRANCH must be '${EXPECT_BRANCH}' (next minor line); tip has ${CURRENT_VERSION} on origin/${BRANCH}" >&2 + exit 1 + fi + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + echo "=== Positive minor test: ${CURRENT_VERSION} → ${NEW_VERSION} (WORKFLOW=minor) ===" +fi + +export BRANCH +export NEW_VERSION +export WORKFLOW +unset SKIP_VERSION_VALIDATION 2>/dev/null || true + +echo "BRANCH=${BRANCH}" +echo "NEW_VERSION=${NEW_VERSION}" +echo "WORKFLOW=${WORKFLOW}" +echo "CURRENT (origin/${BRANCH})=${CURRENT_VERSION}" + +if [[ "$DRY_RUN" == "true" ]]; then + echo "=== --dry-run: not invoking validator ===" + exit 0 +fi + +echo "=== Running validate_version_bump_params.sh ===" +set +e +"$VALIDATOR" +RC=$? +set -e + +if [[ "$NEGATIVE" == "true" ]]; then + if [[ "$RC" -eq 0 ]]; then + echo "ERROR: negative test expected validator to fail, but it exited 0" >&2 + exit 1 + fi + echo "OK: negative test — validator failed as expected (exit ${RC})" + exit 0 +fi + +if [[ "$RC" -ne 0 ]]; then + echo "ERROR: validator exited ${RC}" >&2 + exit "$RC" +fi + +echo "OK: validator succeeded" diff --git a/dev-tools/unittest/test_version_bump_validation.py b/dev-tools/unittest/test_version_bump_validation.py new file mode 100644 index 000000000..5f04796e7 --- /dev/null +++ b/dev-tools/unittest/test_version_bump_validation.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. + +"""Pytest tests for dev-tools/version_bump_validation.py (Buildkite bump rules).""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_DEV_TOOLS = Path(__file__).resolve().parents[1] +if str(_DEV_TOOLS) not in sys.path: + sys.path.insert(0, str(_DEV_TOOLS)) + +import version_bump_validation as vbu # noqa: E402 + +_REPO_ROOT = _DEV_TOOLS.parent +_VALIDATOR_SCRIPT = _DEV_TOOLS / "validate_version_bump_params.sh" +_MODULE = _DEV_TOOLS / "version_bump_validation.py" + + +def test_parse_semver_ok() -> None: + assert vbu.parse_semver("9.5.1") == (9, 5, 1) + + +def test_parse_semver_rejects() -> None: + assert vbu.parse_semver("9.5") is None + assert vbu.parse_semver("v9.5.0") is None + assert vbu.parse_semver("9.5.0.1") is None + + +def test_patch_ok_consecutive() -> None: + vbu.validate_version_bump_params( + current_version="9.5.0", + new_version="9.5.1", + branch="9.5", + workflow="patch", + ) + + +def test_patch_ok_noop_same_version() -> None: + vbu.validate_version_bump_params( + current_version="9.5.1", + new_version="9.5.1", + branch="9.5", + workflow="patch", + ) + + +def test_patch_rejects_skip() -> None: + with pytest.raises(ValueError): + vbu.validate_version_bump_params( + current_version="9.5.0", + new_version="9.5.2", + branch="9.5", + workflow="patch", + ) + + +def test_patch_rejects_wrong_branch_minor() -> None: + with pytest.raises(ValueError): + vbu.validate_version_bump_params( + current_version="9.5.0", + new_version="9.5.1", + branch="9.4", + workflow="patch", + ) + + +def test_patch_rejects_minor_mismatch() -> None: + with pytest.raises(ValueError): + vbu.validate_version_bump_params( + current_version="9.4.9", + new_version="9.5.1", + branch="9.5", + workflow="patch", + ) + + +def test_minor_ok() -> None: + vbu.validate_version_bump_params( + current_version="9.4.12", + new_version="9.5.0", + branch="9.5", + workflow="minor", + ) + + +def test_minor_rejects_patch_not_zero() -> None: + with pytest.raises(ValueError): + vbu.validate_version_bump_params( + current_version="9.4.12", + new_version="9.5.1", + branch="9.5", + workflow="minor", + ) + + +def test_minor_rejects_wrong_increment() -> None: + with pytest.raises(ValueError): + vbu.validate_version_bump_params( + current_version="9.4.12", + new_version="9.6.0", + branch="9.6", + workflow="minor", + ) + + +def test_invalid_workflow() -> None: + with pytest.raises(ValueError): + vbu.validate_workflow_name("major") + + +def test_cli_validate_patch_ok() -> None: + rc = subprocess.call( + [ + sys.executable, + str(_MODULE), + "validate", + "--current", + "9.5.0", + "--new", + "9.5.1", + "--branch", + "9.5", + "--workflow", + "patch", + ], + cwd=str(_REPO_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + assert rc == 0 + + +def test_cli_validate_patch_negative() -> None: + rc = subprocess.call( + [ + sys.executable, + str(_MODULE), + "validate", + "--current", + "9.5.0", + "--new", + "9.5.2", + "--branch", + "9.5", + "--workflow", + "patch", + ], + cwd=str(_REPO_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + assert rc != 0 + + +def test_cli_validate_minor_ok() -> None: + rc = subprocess.call( + [ + sys.executable, + str(_MODULE), + "validate", + "--current", + "9.4.8", + "--new", + "9.5.0", + "--branch", + "9.5", + "--workflow", + "minor", + ], + cwd=str(_REPO_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + assert rc == 0 + + +@pytest.mark.skipif( + not _VALIDATOR_SCRIPT.is_file(), + reason="validate_version_bump_params.sh missing", +) +def test_shell_skip_validation_env() -> None: + env = os.environ.copy() + env["SKIP_VERSION_VALIDATION"] = "true" + env.pop("NEW_VERSION", None) + env.pop("BRANCH", None) + out = subprocess.run( + ["/bin/bash", str(_VALIDATOR_SCRIPT)], + cwd=str(_REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=5, + ) + assert out.returncode == 0, out.stderr + out.stdout diff --git a/dev-tools/validate_version_bump_params.sh b/dev-tools/validate_version_bump_params.sh new file mode 100755 index 000000000..14bef29f8 --- /dev/null +++ b/dev-tools/validate_version_bump_params.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Validates NEW_VERSION / BRANCH / WORKFLOW against elasticsearchVersion on the +# remote release branch before ml-cpp-version-bump runs bump_version.sh. +# Semantic rules live in version_bump_validation.py (unit-tested). +# +# Environment: +# NEW_VERSION — required target stack version (MAJOR.MINOR.PATCH), unless skipped +# BRANCH — required release branch (e.g. 9.5), unless skipped +# WORKFLOW — patch (default) or minor +# SKIP_VERSION_VALIDATION — set to "true" to skip (emergency override only) +# PYTHON — interpreter (default: python3) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="${PYTHON:-python3}" +VALIDATION_PY="${SCRIPT_DIR}/version_bump_validation.py" + +SKIP_VERSION_VALIDATION="${SKIP_VERSION_VALIDATION:-false}" + +if [[ "$SKIP_VERSION_VALIDATION" == "true" ]]; then + echo "WARNING: SKIP_VERSION_VALIDATION=true — version increment checks skipped." >&2 + exit 0 +fi + +: "${NEW_VERSION:?NEW_VERSION must be set}" +: "${BRANCH:?BRANCH must be set}" + +WORKFLOW="${WORKFLOW:-patch}" + +echo "=== Version bump validation ===" +echo "WORKFLOW: ${WORKFLOW}" +echo "NEW_VERSION: ${NEW_VERSION}" +echo "BRANCH: ${BRANCH}" + +echo "Fetching origin/${BRANCH}..." +git fetch origin "$BRANCH" + +if ! git cat-file -e FETCH_HEAD:gradle.properties 2>/dev/null; then + echo "ERROR: gradle.properties missing at FETCH_HEAD (origin/${BRANCH})" >&2 + exit 1 +fi + +CURRENT_VERSION=$( + git show FETCH_HEAD:gradle.properties | grep '^elasticsearchVersion=' | head -1 | cut -d= -f2 | tr -d '[:space:]' +) + +if [[ -z "$CURRENT_VERSION" ]]; then + echo "ERROR: could not read elasticsearchVersion from origin/${BRANCH} gradle.properties" >&2 + exit 1 +fi + +echo "Current version on origin/${BRANCH}: ${CURRENT_VERSION}" + +exec "$PYTHON" "$VALIDATION_PY" validate-and-report \ + --current "$CURRENT_VERSION" \ + --new "$NEW_VERSION" \ + --branch "$BRANCH" \ + --workflow "$WORKFLOW" diff --git a/dev-tools/version_bump_validation.py b/dev-tools/version_bump_validation.py new file mode 100644 index 000000000..f4a2d6e4c --- /dev/null +++ b/dev-tools/version_bump_validation.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +"""Rules for ml-cpp release version bump parameters (Buildkite / release-eng). + +Used by dev-tools/validate_version_bump_params.sh and unit-tested under +dev-tools/unittest/. + +Run tests from ``dev-tools/``: + + ./run_dev_tools_tests.sh + +Or (after ``pip install -r dev-tools/test-requirements.txt``): + + cd dev-tools && python3 -m pytest -c pytest.ini +""" + +from __future__ import annotations + +import argparse +import re +import sys +from typing import Optional, Tuple + +SEMVER_RE = re.compile(r"^([0-9]+)\.([0-9]+)\.([0-9]+)$") +BRANCH_RE = re.compile(r"^([0-9]+)\.([0-9]+)$") + + +def parse_semver(version: str) -> Optional[Tuple[int, int, int]]: + m = SEMVER_RE.match(version.strip()) + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + +def parse_release_branch(branch: str) -> Optional[Tuple[int, int]]: + m = BRANCH_RE.match(branch.strip()) + if not m: + return None + return (int(m.group(1)), int(m.group(2))) + + +def validate_workflow_name(workflow: str) -> None: + if workflow not in ("patch", "minor"): + raise ValueError( + f"WORKFLOW must be 'patch' or 'minor', got {workflow!r}" + ) + + +def validate_version_bump_params( + *, + current_version: str, + new_version: str, + branch: str, + workflow: str, +) -> None: + """Validate release bump parameters. Raises ValueError on failure. + + When current_version == new_version, the bump is a no-op and always valid. + """ + validate_workflow_name(workflow) + + new_t = parse_semver(new_version) + if new_t is None: + raise ValueError( + f"NEW_VERSION must be MAJOR.MINOR.PATCH (digits only), got {new_version!r}" + ) + new_major, new_minor, new_patch = new_t + + br = parse_release_branch(branch) + if br is None: + raise ValueError( + f"BRANCH must be MAJOR.MINOR (e.g. 9.5), got {branch!r}" + ) + br_major, br_minor = br + if br_major != new_major or br_minor != new_minor: + raise ValueError( + f"BRANCH {branch!r} must match MAJOR.MINOR of NEW_VERSION " + f"({new_major}.{new_minor}), got NEW_VERSION {new_version!r}" + ) + + cur_t = parse_semver(current_version) + if cur_t is None: + raise ValueError( + "elasticsearchVersion on branch must be MAJOR.MINOR.PATCH, " + f"got {current_version!r}" + ) + cur_major, cur_minor, cur_patch = cur_t + + if current_version.strip() == new_version.strip(): + return + + if workflow == "patch": + if cur_major != new_major or cur_minor != new_minor: + raise ValueError( + "patch bump requires same MAJOR.MINOR as current " + f"({cur_major}.{cur_minor} vs {new_major}.{new_minor})" + ) + expected_patch = cur_patch + 1 + if new_patch != expected_patch: + raise ValueError( + "patch workflow expects NEW_VERSION patch = current patch + 1 " + f"({current_version} → {new_major}.{new_minor}.{expected_patch}), " + f"got {new_version}" + ) + return + + # minor + if new_patch != 0: + raise ValueError( + f"minor workflow expects NEW_VERSION with PATCH=0, got {new_version!r}" + ) + if cur_major != new_major: + raise ValueError( + f"minor bump must keep the same MAJOR ({cur_major} vs {new_major})" + ) + expected_minor = cur_minor + 1 + if new_minor != expected_minor: + raise ValueError( + "minor workflow expects MINOR = current minor + 1 " + f"({cur_minor} → {expected_minor}), got {new_minor}" + ) + + +def _cmd_validate(args: argparse.Namespace) -> int: + try: + validate_version_bump_params( + current_version=args.current, + new_version=args.new, + branch=args.branch, + workflow=args.workflow, + ) + except ValueError as e: + print(f"ERROR: {e}", file=sys.stderr) + return 1 + return 0 + + +def _cmd_validate_and_report(args: argparse.Namespace) -> int: + rc = _cmd_validate(args) + if rc != 0: + return rc + cur = args.current.strip() + new = args.new.strip() + if cur == new: + print(f"OK: branch already at {new} — bump step will no-op.") + elif args.workflow == "patch": + print(f"OK: patch increment {cur} → {new}") + else: + print(f"OK: minor increment {cur} → {new}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="ml-cpp version bump parameter validation" + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_val = sub.add_parser( + "validate", + help="check current/new/branch/workflow (same rules as Buildkite)", + ) + p_val.add_argument("--current", required=True, help="elasticsearchVersion on branch") + p_val.add_argument("--new", required=True, dest="new", help="NEW_VERSION") + p_val.add_argument("--branch", required=True, help="BRANCH (MAJOR.MINOR)") + p_val.add_argument( + "--workflow", + required=True, + choices=("patch", "minor"), + help="WORKFLOW", + ) + p_val.set_defaults(func=_cmd_validate) + + p_rep = sub.add_parser( + "validate-and-report", + help="validate and print the same OK lines as validate_version_bump_params.sh", + ) + p_rep.add_argument("--current", required=True) + p_rep.add_argument("--new", required=True, dest="new") + p_rep.add_argument("--branch", required=True) + p_rep.add_argument("--workflow", required=True, choices=("patch", "minor")) + p_rep.set_defaults(func=_cmd_validate_and_report) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) From d4c873ab33478d82c7b9ade0855bcf6fa8d687a6 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Mon, 4 May 2026 14:13:21 +1200 Subject: [PATCH 12/32] [ML] Remove redundant test_validate_version_bump_local.sh Pytest plus manual validate_version_bump_params.sh usage is sufficient. Co-authored-by: Cursor --- dev-tools/test_validate_version_bump_local.sh | 188 ------------------ 1 file changed, 188 deletions(-) delete mode 100755 dev-tools/test_validate_version_bump_local.sh diff --git a/dev-tools/test_validate_version_bump_local.sh b/dev-tools/test_validate_version_bump_local.sh deleted file mode 100755 index c3d2a9c6c..000000000 --- a/dev-tools/test_validate_version_bump_local.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/bin/bash -# -# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -# or more contributor license agreements. Licensed under the Elastic License -# 2.0 and the following additional limitation. Functionality enabled by the -# files subject to the Elastic License 2.0 may only be used in production when -# invoked by an Elasticsearch process with a license key installed that permits -# use of machine learning features. You may not use this file except in -# compliance with the Elastic License 2.0 and the foregoing additional -# limitation. -# -# Local helper to exercise dev-tools/validate_version_bump_params.sh against -# origin/: fetch the branch, derive NEW_VERSION from the current -# elasticsearchVersion in gradle.properties, export env, run the validator. -# -# Usage: -# ./dev-tools/test_validate_version_bump_local.sh [options] -# -# Options: -# --negative Use an intentionally invalid NEW_VERSION (expects validator failure) -# --workflow patch | minor Override workflow (default: patch for positive, -# minor mode targets MAJOR.(MINOR+1).0 on the next minor line) -# --dry-run Print env and computed versions only; do not run validator -# -# Examples: -# ./dev-tools/test_validate_version_bump_local.sh 9.5 -# ./dev-tools/test_validate_version_bump_local.sh 9.5 --negative -# ./dev-tools/test_validate_version_bump_local.sh 9.5 --workflow minor --dry-run - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -VALIDATOR="${SCRIPT_DIR}/validate_version_bump_params.sh" - -usage() { - echo "Usage: $0 [--negative] [--workflow patch|minor] [--dry-run]" >&2 - echo " BRANCH Release branch MAJOR.MINOR (e.g. 9.5), matching origin." >&2 - exit 1 -} - -BRANCH="" -NEGATIVE="false" -WORKFLOW_OVERRIDE="" -DRY_RUN="false" - -while [[ $# -gt 0 ]]; do - case "$1" in - --negative) - NEGATIVE="true" - shift - ;; - --workflow) - WORKFLOW_OVERRIDE="${2:?}" - shift 2 - ;; - --dry-run) - DRY_RUN="true" - shift - ;; - -h | --help) - usage - ;; - *) - if [[ -n "$BRANCH" ]]; then - echo "ERROR: unexpected argument: $1" >&2 - usage - fi - BRANCH="$1" - shift - ;; - esac -done - -if [[ -z "$BRANCH" ]]; then - usage -fi - -cd "$REPO_ROOT" - -if [[ ! -x "$VALIDATOR" && ! -f "$VALIDATOR" ]]; then - echo "ERROR: validator not found at ${VALIDATOR}" >&2 - exit 1 -fi - -parse_triple() { - local v="$1" - local re='^([0-9]+)\.([0-9]+)\.([0-9]+)$' - if [[ "$v" =~ $re ]]; then - _M="${BASH_REMATCH[1]}" - _N="${BASH_REMATCH[2]}" - _P="${BASH_REMATCH[3]}" - return 0 - fi - return 1 -} - -echo "=== Fetch origin/${BRANCH} ===" -git fetch origin "$BRANCH" - -CURRENT_VERSION=$( - git show FETCH_HEAD:gradle.properties | grep '^elasticsearchVersion=' | head -1 | cut -d= -f2 | tr -d '[:space:]' -) - -if [[ -z "$CURRENT_VERSION" ]]; then - echo "ERROR: could not read elasticsearchVersion from FETCH_HEAD" >&2 - exit 1 -fi - -if ! parse_triple "$CURRENT_VERSION"; then - echo "ERROR: invalid elasticsearchVersion on branch: '${CURRENT_VERSION}'" >&2 - exit 1 -fi - -MAJOR="$_M" -MINOR="$_N" -PATCH="$_P" - -WORKFLOW="${WORKFLOW_OVERRIDE:-patch}" - -if [[ "$WORKFLOW" != "patch" && "$WORKFLOW" != "minor" ]]; then - echo "ERROR: --workflow must be patch or minor" >&2 - exit 1 -fi - -if [[ "$NEGATIVE" == "true" && "$WORKFLOW" == "minor" ]]; then - echo "ERROR: --negative is only implemented for patch workflow" >&2 - exit 1 -fi - -if [[ "$WORKFLOW" == "patch" ]]; then - if [[ "$BRANCH" != "${MAJOR}.${MINOR}" ]]; then - echo "ERROR: BRANCH '${BRANCH}' must match MAJOR.MINOR of current version (${MAJOR}.${MINOR}) for patch test" >&2 - exit 1 - fi - if [[ "$NEGATIVE" == "true" ]]; then - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 2))" - echo "=== Negative test: invalid patch jump ${CURRENT_VERSION} → ${NEW_VERSION} (expected failure) ===" - else - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - echo "=== Positive patch test: ${CURRENT_VERSION} → ${NEW_VERSION} ===" - fi -else - EXPECT_BRANCH="${MAJOR}.$((MINOR + 1))" - if [[ "$BRANCH" != "$EXPECT_BRANCH" ]]; then - echo "ERROR: for minor workflow, BRANCH must be '${EXPECT_BRANCH}' (next minor line); tip has ${CURRENT_VERSION} on origin/${BRANCH}" >&2 - exit 1 - fi - NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" - echo "=== Positive minor test: ${CURRENT_VERSION} → ${NEW_VERSION} (WORKFLOW=minor) ===" -fi - -export BRANCH -export NEW_VERSION -export WORKFLOW -unset SKIP_VERSION_VALIDATION 2>/dev/null || true - -echo "BRANCH=${BRANCH}" -echo "NEW_VERSION=${NEW_VERSION}" -echo "WORKFLOW=${WORKFLOW}" -echo "CURRENT (origin/${BRANCH})=${CURRENT_VERSION}" - -if [[ "$DRY_RUN" == "true" ]]; then - echo "=== --dry-run: not invoking validator ===" - exit 0 -fi - -echo "=== Running validate_version_bump_params.sh ===" -set +e -"$VALIDATOR" -RC=$? -set -e - -if [[ "$NEGATIVE" == "true" ]]; then - if [[ "$RC" -eq 0 ]]; then - echo "ERROR: negative test expected validator to fail, but it exited 0" >&2 - exit 1 - fi - echo "OK: negative test — validator failed as expected (exit ${RC})" - exit 0 -fi - -if [[ "$RC" -ne 0 ]]; then - echo "ERROR: validator exited ${RC}" >&2 - exit "$RC" -fi - -echo "OK: validator succeeded" From cf2f76c2be4eecf1b04bdf2ef1c9ab7d3c1bd3cf Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Mon, 4 May 2026 14:17:31 +1200 Subject: [PATCH 13/32] [ML] Add opt-in git-fetch integration tests for version bump validation Run with VERSION_BUMP_GIT_INTEGRATION=1 and VERSION_BUMP_TEST_BRANCH; document markers in pytest.ini. Co-authored-by: Cursor --- .../unittest/test_version_bump_validation.py | 138 +++++++++++++++++- dev-tools/version_bump_validation.py | 6 +- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/dev-tools/unittest/test_version_bump_validation.py b/dev-tools/unittest/test_version_bump_validation.py index 5f04796e7..6108ddd4a 100644 --- a/dev-tools/unittest/test_version_bump_validation.py +++ b/dev-tools/unittest/test_version_bump_validation.py @@ -8,7 +8,18 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. -"""Pytest tests for dev-tools/version_bump_validation.py (Buildkite bump rules).""" +"""Pytest tests for dev-tools/version_bump_validation.py (Buildkite bump rules). + +Integration tests (real ``git fetch`` + ``validate_version_bump_params.sh``) are +opt-in so CI stays deterministic: + + export VERSION_BUMP_GIT_INTEGRATION=1 + export VERSION_BUMP_TEST_BRANCH=9.5 # MAJOR.MINOR branch that exists on origin + ./dev-tools/run_dev_tools_tests.sh + +Optional: ``VERSION_BUMP_SKIP_NEGATIVE_INTEGRATION=1`` to skip the negative +``patch+2`` check only. +""" from __future__ import annotations @@ -206,3 +217,128 @@ def test_shell_skip_validation_env() -> None: timeout=5, ) assert out.returncode == 0, out.stderr + out.stdout + + +def _integration_requested() -> bool: + return os.environ.get("VERSION_BUMP_GIT_INTEGRATION") == "1" + + +def _integration_branch() -> str | None: + b = os.environ.get("VERSION_BUMP_TEST_BRANCH", "").strip() + return b or None + + +def _read_version_from_fetch_head(repo: Path) -> str: + proc = subprocess.run( + ["git", "show", "FETCH_HEAD:gradle.properties"], + cwd=str(repo), + capture_output=True, + text=True, + timeout=60, + ) + if proc.returncode != 0: + raise AssertionError( + f"git show FETCH_HEAD:gradle.properties failed: {proc.stderr}" + ) + for line in proc.stdout.splitlines(): + if line.startswith("elasticsearchVersion="): + return line.split("=", 1)[1].strip() + raise AssertionError("elasticsearchVersion not found in FETCH_HEAD gradle.properties") + + +@pytest.fixture +def git_patch_integration_branch() -> str: + """Release branch MAJOR.MINOR; requires network + origin ref.""" + if not _integration_requested(): + pytest.skip( + "Set VERSION_BUMP_GIT_INTEGRATION=1 and VERSION_BUMP_TEST_BRANCH " + "(e.g. 9.5) to run git integration tests." + ) + br = _integration_branch() + if not br: + pytest.skip("VERSION_BUMP_TEST_BRANCH is not set.") + return br + + +@pytest.mark.integration +@pytest.mark.skipif( + not _VALIDATOR_SCRIPT.is_file(), + reason="validate_version_bump_params.sh missing", +) +def test_integration_patch_validate_script_with_git_fetch(git_patch_integration_branch: str) -> None: + """Run validate_version_bump_params.sh after fetch; NEW_VERSION = patch+1 from origin.""" + branch = git_patch_integration_branch + fetch = subprocess.run( + ["git", "fetch", "origin", branch], + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + timeout=120, + ) + assert fetch.returncode == 0, fetch.stderr + fetch.stdout + + cur = _read_version_from_fetch_head(_REPO_ROOT) + triple = vbu.parse_semver(cur) + assert triple is not None, f"unexpected elasticsearchVersion on branch: {cur!r}" + maj, mino, pat = triple + new_version = f"{maj}.{mino}.{pat + 1}" + + env = os.environ.copy() + env["NEW_VERSION"] = new_version + env["BRANCH"] = branch + env["WORKFLOW"] = "patch" + env.pop("SKIP_VERSION_VALIDATION", None) + + out = subprocess.run( + ["/bin/bash", str(_VALIDATOR_SCRIPT)], + cwd=str(_REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=180, + ) + assert out.returncode == 0, out.stderr + out.stdout + + +@pytest.mark.integration +@pytest.mark.skipif( + not _VALIDATOR_SCRIPT.is_file(), + reason="validate_version_bump_params.sh missing", +) +@pytest.mark.skipif( + os.environ.get("VERSION_BUMP_SKIP_NEGATIVE_INTEGRATION") == "1", + reason="VERSION_BUMP_SKIP_NEGATIVE_INTEGRATION=1", +) +def test_integration_patch_validate_script_rejects_bad_jump(git_patch_integration_branch: str) -> None: + """Same fetch as production path; NEW_VERSION = patch+2 must fail validation.""" + branch = git_patch_integration_branch + fetch = subprocess.run( + ["git", "fetch", "origin", branch], + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + timeout=120, + ) + assert fetch.returncode == 0, fetch.stderr + fetch.stdout + + cur = _read_version_from_fetch_head(_REPO_ROOT) + triple = vbu.parse_semver(cur) + assert triple is not None + maj, mino, pat = triple + bad_version = f"{maj}.{mino}.{pat + 2}" + + env = os.environ.copy() + env["NEW_VERSION"] = bad_version + env["BRANCH"] = branch + env["WORKFLOW"] = "patch" + env.pop("SKIP_VERSION_VALIDATION", None) + + out = subprocess.run( + ["/bin/bash", str(_VALIDATOR_SCRIPT)], + cwd=str(_REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=180, + ) + assert out.returncode != 0, "validator should reject non-consecutive patch bump" diff --git a/dev-tools/version_bump_validation.py b/dev-tools/version_bump_validation.py index f4a2d6e4c..f289262a7 100644 --- a/dev-tools/version_bump_validation.py +++ b/dev-tools/version_bump_validation.py @@ -17,9 +17,9 @@ ./run_dev_tools_tests.sh -Or (after ``pip install -r dev-tools/test-requirements.txt``): - - cd dev-tools && python3 -m pytest -c pytest.ini +Optional git integration (real ``git fetch`` + shell validator): set +``VERSION_BUMP_GIT_INTEGRATION=1`` and ``VERSION_BUMP_TEST_BRANCH=MAJOR.MINOR``. +See ``unittest/test_version_bump_validation.py`` module docstring. """ from __future__ import annotations From 6c0cf8057baffedf7247e0c568c22501b7fd0b64 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 10:00:45 +1200 Subject: [PATCH 14/32] [ML] Add temporary empty-commit probe to version-bump git auth step Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 4 ++-- dev-tools/git_push_auth_probe.sh | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 1075841d5..374fad36e 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -12,7 +12,7 @@ # It is intended to be triggered by the centralized release-eng pipeline. # # Patch workflow: validate NEW_VERSION/BRANCH/WORKFLOW, verify git push -# credentials (dry-run), bump version on BRANCH, then wait for staging and +# credentials (dry-run) and temporary empty-commit probe, bump version on BRANCH, then wait for staging and # snapshot artifact JSON to publish NEW_VERSION. Set WORKFLOW=minor for minor # bumps; defaults to patch. @@ -77,7 +77,7 @@ def main(): ], }, { - "label": "Verify git push credentials (dry-run)", + "label": "Verify git push (dry-run) + commit [temp]", "key": "git-push-auth-probe", "depends_on": "validate-version-bump", "agents": { diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh index 7059d3fc2..0a6dbd1f3 100755 --- a/dev-tools/git_push_auth_probe.sh +++ b/dev-tools/git_push_auth_probe.sh @@ -10,7 +10,8 @@ # limitation. # # Verifies that the current checkout can authenticate for git push to origin, -# without updating any remote refs (uses "git push --dry-run"). +# without updating any remote refs (uses "git push --dry-run"), and (TEMPORARY) +# that an empty commit can be created and undone — same constraints as bump_version.sh. # # Intended for the ml-cpp-version-bump Buildkite pipeline (same agent + remotes # as dev-tools/bump_version.sh). Uses a disposable ref name under refs/heads/ci/ @@ -39,3 +40,20 @@ if ! git push --dry-run "${REMOTE}" "HEAD:${PROBE_REF}"; then fi echo "OK: git push --dry-run succeeded for ${REMOTE}." + +# TEMPORARY — remove once CI git identity + commit permissions are confirmed end-to-end. +# Mirrors configure_git in dev-tools/bump_version.sh (empty commit, then restore HEAD). +echo "" +echo "=== TEMPORARY: Git commit probe (empty commit, then reset) ===" +git config user.name elasticsearchmachine +git config user.email 'infra-root+elasticsearchmachine@elastic.co' +PRE_HEAD="$(git rev-parse HEAD)" +if ! git commit --allow-empty -m "[CI] Temporary empty commit probe"; then + echo "ERROR: git commit failed — check git identity and repo permissions." >&2 + exit 1 +fi +if ! git reset --hard "${PRE_HEAD}"; then + echo "ERROR: git reset --hard failed after probe commit — workspace may be dirty." >&2 + exit 1 +fi +echo "OK: git commit succeeded; HEAD restored to ${PRE_HEAD}." From a2efe35f8215fe8f6d410af05c5fadebb37bac25 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 10:27:27 +1200 Subject: [PATCH 15/32] [ML] Remove temporary commit probe; tidy version bump pipeline PR Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 4 ++-- .github/workflows/run-patch-release.yml | 4 ++-- dev-tools/git_push_auth_probe.sh | 20 +------------------- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 374fad36e..1075841d5 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -12,7 +12,7 @@ # It is intended to be triggered by the centralized release-eng pipeline. # # Patch workflow: validate NEW_VERSION/BRANCH/WORKFLOW, verify git push -# credentials (dry-run) and temporary empty-commit probe, bump version on BRANCH, then wait for staging and +# credentials (dry-run), bump version on BRANCH, then wait for staging and # snapshot artifact JSON to publish NEW_VERSION. Set WORKFLOW=minor for minor # bumps; defaults to patch. @@ -77,7 +77,7 @@ def main(): ], }, { - "label": "Verify git push (dry-run) + commit [temp]", + "label": "Verify git push credentials (dry-run)", "key": "git-push-auth-probe", "depends_on": "validate-version-bump", "agents": { diff --git a/.github/workflows/run-patch-release.yml b/.github/workflows/run-patch-release.yml index a600f2aaf..927bcbcec 100644 --- a/.github/workflows/run-patch-release.yml +++ b/.github/workflows/run-patch-release.yml @@ -1,5 +1,5 @@ -# Skeleton: patch-oriented version bump via GitHub Actions (APM-style entry point). -# Buildkite (or humans) can trigger this with `gh workflow run` once secrets exist. +# Optional manual patch bump via workflow_dispatch (same dev-tools/bump_version.sh as Buildkite). +# Primary automation is the ml-cpp-version-bump pipeline; use this when operating via GitHub App. # # Required repository secrets (names are suggestions — align with infra / reuse an # existing org GitHub App after review): diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh index 0a6dbd1f3..7059d3fc2 100755 --- a/dev-tools/git_push_auth_probe.sh +++ b/dev-tools/git_push_auth_probe.sh @@ -10,8 +10,7 @@ # limitation. # # Verifies that the current checkout can authenticate for git push to origin, -# without updating any remote refs (uses "git push --dry-run"), and (TEMPORARY) -# that an empty commit can be created and undone — same constraints as bump_version.sh. +# without updating any remote refs (uses "git push --dry-run"). # # Intended for the ml-cpp-version-bump Buildkite pipeline (same agent + remotes # as dev-tools/bump_version.sh). Uses a disposable ref name under refs/heads/ci/ @@ -40,20 +39,3 @@ if ! git push --dry-run "${REMOTE}" "HEAD:${PROBE_REF}"; then fi echo "OK: git push --dry-run succeeded for ${REMOTE}." - -# TEMPORARY — remove once CI git identity + commit permissions are confirmed end-to-end. -# Mirrors configure_git in dev-tools/bump_version.sh (empty commit, then restore HEAD). -echo "" -echo "=== TEMPORARY: Git commit probe (empty commit, then reset) ===" -git config user.name elasticsearchmachine -git config user.email 'infra-root+elasticsearchmachine@elastic.co' -PRE_HEAD="$(git rev-parse HEAD)" -if ! git commit --allow-empty -m "[CI] Temporary empty commit probe"; then - echo "ERROR: git commit failed — check git identity and repo permissions." >&2 - exit 1 -fi -if ! git reset --hard "${PRE_HEAD}"; then - echo "ERROR: git reset --hard failed after probe commit — workspace may be dirty." >&2 - exit 1 -fi -echo "OK: git commit succeeded; HEAD restored to ${PRE_HEAD}." From 3d2f70b86fca2a655ac1066d872d28e8d8c4743f Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 10:37:29 +1200 Subject: [PATCH 16/32] [ML] Drop run-patch-release workflow (no GitHub App secrets) Co-authored-by: Cursor --- .github/workflows/run-patch-release.yml | 115 ------------------------ 1 file changed, 115 deletions(-) delete mode 100644 .github/workflows/run-patch-release.yml diff --git a/.github/workflows/run-patch-release.yml b/.github/workflows/run-patch-release.yml deleted file mode 100644 index 927bcbcec..000000000 --- a/.github/workflows/run-patch-release.yml +++ /dev/null @@ -1,115 +0,0 @@ -# Optional manual patch bump via workflow_dispatch (same dev-tools/bump_version.sh as Buildkite). -# Primary automation is the ml-cpp-version-bump pipeline; use this when operating via GitHub App. -# -# Required repository secrets (names are suggestions — align with infra / reuse an -# existing org GitHub App after review): -# ML_CPP_RELEASE_GITHUB_APP_ID -# ML_CPP_RELEASE_GITHUB_APP_PRIVATE_KEY (PEM for the app; multiline secret) -# -# Optional (Slack — mirror elastic/apm-server when ready): -# SLACK_BOT_TOKEN -# -# Install the GitHub App on elastic/ml-cpp with contents:write + pull-requests:write -# (same class of permissions as elastic/apm-server run-patch-release.yml). - -name: ml-cpp run patch release - -on: - workflow_dispatch: - inputs: - branch: - description: "Git branch to bump (e.g. 9.5 or feature/foo — must exist on origin)" - required: true - type: string - new_version: - description: "Target elasticsearchVersion in gradle.properties (x.y.z)" - required: true - type: string - dry_run: - description: "If true, bump_version.sh runs with DRY_RUN=true (no git push)" - required: false - type: boolean - default: true - -concurrency: - group: ${{ github.workflow }}-${{ github.run_id }} - -permissions: - contents: read - -jobs: - prepare: - name: Prepare (validate inputs) - runs-on: ubuntu-latest - outputs: - branch: ${{ steps.meta.outputs.branch }} - new_version: ${{ steps.meta.outputs.new_version }} - dry_run: ${{ steps.meta.outputs.dry_run }} - steps: - - name: Validate and emit outputs - id: meta - shell: bash - env: - BRANCH_IN: ${{ inputs.branch }} - VERSION_IN: ${{ inputs.new_version }} - DRY_IN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - if [[ -z "${BRANCH_IN// }" ]]; then - echo "::error::branch must not be empty" - exit 1 - fi - if ! echo "${VERSION_IN}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::new_version must look like major.minor.patch (digits only)" - exit 1 - fi - { - echo "branch=${BRANCH_IN}" - echo "new_version=${VERSION_IN}" - } >> "${GITHUB_OUTPUT}" - if [[ "${DRY_IN}" == "true" ]]; then - echo "dry_run=true" >> "${GITHUB_OUTPUT}" - else - echo "dry_run=false" >> "${GITHUB_OUTPUT}" - fi - - bump: - name: Bump version (GitHub App token) - runs-on: ubuntu-latest - needs: - - prepare - steps: - - name: Mint installation token (GitHub App) - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.ML_CPP_RELEASE_GITHUB_APP_ID }} - private-key: ${{ secrets.ML_CPP_RELEASE_GITHUB_APP_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - - - name: Checkout target branch - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ needs.prepare.outputs.branch }} - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - # Match dev-tools/bump_version.sh configure_git() so commits stay consistent. - # Swap for elastic/oblt-actions/git/setup@v1 if infra prefers the shared action. - - name: Configure git (service identity) - shell: bash - run: | - git config user.name elasticsearchmachine - git config user.email 'infra-root+elasticsearchmachine@elastic.co' - - - name: Run bump script - env: - NEW_VERSION: ${{ needs.prepare.outputs.new_version }} - BRANCH: ${{ needs.prepare.outputs.branch }} - DRY_RUN: ${{ needs.prepare.outputs.dry_run }} - shell: bash - run: | - set -euo pipefail - bash dev-tools/bump_version.sh From 16d18b7cd047752cba6ea9ae9dc9ebafef05e176 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 13:25:21 +1200 Subject: [PATCH 17/32] [ML] Version bump pipeline: patch-only validation, CI probes, DRY_RUN DRA skip - Restrict validation to patch bumps; enforce WORKFLOW=patch in shell - Revalidate bump after git pull; safer pipefail parsing for gradle version - Git push probe: dry-run refs/heads/${BRANCH} when BRANCH set (Buildkite) - Skip DRA json-watcher when DRY_RUN=true - Split pip bootstrap to dev_tools_pytest.sh; clearer errors for pytest/git root - Slack notification: drop Workflow line Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 10 +- .../send_version_bump_notification.sh | 2 - dev-tools/bump_version.sh | 35 ++++++- dev-tools/git_push_auth_probe.sh | 46 +++++++-- .../unittest/test_version_bump_validation.py | 93 ++++++------------- dev-tools/validate_version_bump_params.sh | 22 +++-- dev-tools/version_bump_validation.py | 70 ++++---------- 7 files changed, 132 insertions(+), 146 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 1075841d5..6ce3832f4 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -11,10 +11,9 @@ # This script generates JSON for the ml-cpp version bump pipeline. # It is intended to be triggered by the centralized release-eng pipeline. # -# Patch workflow: validate NEW_VERSION/BRANCH/WORKFLOW, verify git push -# credentials (dry-run), bump version on BRANCH, then wait for staging and -# snapshot artifact JSON to publish NEW_VERSION. Set WORKFLOW=minor for minor -# bumps; defaults to patch. +# Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), +# bump version on BRANCH, then wait for staging and snapshot artifact JSON to +# publish NEW_VERSION. When DRY_RUN=true the DRA wait step is skipped (no push). import contextlib @@ -39,10 +38,13 @@ def json_watcher_plugin(url, expected_value): def dra_step(label, key, depends_on, plugins): + # Skip when DRY_RUN=true: bump_version.sh does not push, so artifact JSON + # never reaches NEW_VERSION and the json-watcher would time out (240m). return { "label": label, "key": key, "depends_on": depends_on, + "if": 'build.env("DRY_RUN") != "true"', "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/.buildkite/pipelines/send_version_bump_notification.sh b/.buildkite/pipelines/send_version_bump_notification.sh index b3c7bedac..163b46102 100755 --- a/.buildkite/pipelines/send_version_bump_notification.sh +++ b/.buildkite/pipelines/send_version_bump_notification.sh @@ -23,7 +23,6 @@ notify: :large_green_circle: Version bump pipeline waiting for approval Branch: \${BUILDKITE_BRANCH} New version: \${NEW_VERSION} - Workflow: \${WORKFLOW} Pipeline: \${BUILDKITE_BUILD_URL} if: build.state == "blocked" - slack: @@ -33,6 +32,5 @@ notify: Version bump pipeline finished (${BUILDKITE_BUILD_URL}) Branch: \${BUILDKITE_BRANCH} New version: \${NEW_VERSION} - Workflow: \${WORKFLOW} if: build.state != "blocked" EOL diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 59cffb50b..d197c226e 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -11,12 +11,13 @@ # # Automated patch version bump for the release-eng pipeline. # -# Parameter checks (increment rules, BRANCH vs version) run in -# dev-tools/validate_version_bump_params.sh in the ml-cpp-version-bump pipeline. +# Parameter checks run in dev-tools/validate_version_bump_params.sh before this +# step. After git pull, we re-run the same patch rules against the branch tip so a +# race (another bump / manual commit) cannot downgrade elasticsearchVersion. # # Updates elasticsearchVersion in gradle.properties to NEW_VERSION on BRANCH, -# commits, and pushes. Does not modify .backportrc.json (reserved for a future -# main / minor bump automation change). +# commits, and pushes. Does not modify .backportrc.json (reserved for future +# release automation). # # Set DRY_RUN=true to perform all steps except git push. # @@ -25,6 +26,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="${PYTHON:-python3}" +VALIDATION_PY="${SCRIPT_DIR}/version_bump_validation.py" + : "${NEW_VERSION:?NEW_VERSION must be set}" : "${BRANCH:?BRANCH must be set}" DRY_RUN="${DRY_RUN:-false}" @@ -66,7 +71,27 @@ bump_version_on_branch() { git pull --ff-only origin "$target_branch" local current_version - current_version=$(grep '^elasticsearchVersion=' "$GRADLE_PROPS" | cut -d= -f2) + # pipefail: grep exits 1 when there is no match — do not abort before the + # empty check below. + current_version=$( + grep '^elasticsearchVersion=' "$GRADLE_PROPS" | head -1 | cut -d= -f2 | tr -d '[:space:]' || true + ) + if [[ -z "$current_version" ]]; then + echo "ERROR: could not read elasticsearchVersion from ${GRADLE_PROPS} on ${target_branch}" >&2 + exit 1 + fi + + # Revalidate against post-pull branch tip (race with other bumps / manual edits). + if ! "$PYTHON" "$VALIDATION_PY" validate \ + --current "$current_version" \ + --new "$target_version" \ + --branch "$target_branch" + then + echo "ERROR: version bump does not match branch tip after pull (current=${current_version}, target=${target_version})." >&2 + echo "Refusing to rewrite elasticsearchVersion — resolve manually if another automation advanced the branch." >&2 + exit 1 + fi + if [ "$current_version" = "$target_version" ]; then echo "Version on $target_branch is already $target_version — nothing to do" return 0 diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh index 7059d3fc2..ec1fcad59 100755 --- a/dev-tools/git_push_auth_probe.sh +++ b/dev-tools/git_push_auth_probe.sh @@ -9,31 +9,59 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. # -# Verifies that the current checkout can authenticate for git push to origin, -# without updating any remote refs (uses "git push --dry-run"). +# Verifies that the checkout can authenticate for git push to origin, without +# updating any remote refs (uses "git push --dry-run"). +# +# Primary path (Buildkite sets BRANCH): dry-run FETCH_HEAD:refs/heads/${BRANCH} +# after fetching origin/${BRANCH} — same ref as dev-tools/bump_version.sh, so +# branch protection / rulesets on the release branch are exercised (not only a +# disposable ci/ ref). +# +# Fallback when BRANCH is unset (optional local use): disposable refs/heads/ci/ +# — weaker (does not prove permission on the real release branch). # # Intended for the ml-cpp-version-bump Buildkite pipeline (same agent + remotes -# as dev-tools/bump_version.sh). Uses a disposable ref name under refs/heads/ci/ -# so it does not collide with release branches. +# as dev-tools/bump_version.sh). # # Environment: -# BUILDKITE_BUILD_NUMBER — used to uniquify the probe ref (defaults to "local" -# when unset, e.g. manual runs outside Buildkite). +# BRANCH — release branch (e.g. 9.5). Required when BUILDKITE=true (protected-ref +# probe); optional locally (falls back to refs/heads/ci/...). +# BUILDKITE_BUILD_NUMBER — used to uniquify the fallback ci/ probe ref +# (defaults to "local" when unset). # GIT_REMOTE — remote name (default: origin). set -euo pipefail REMOTE="${GIT_REMOTE:-origin}" BUILD_NUM="${BUILDKITE_BUILD_NUMBER:-local}" -PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" +# Collapse whitespace-only BRANCH (invalid for fetch / refspec). +BRANCH_TRIMMED="${BRANCH:-}" +BRANCH_TRIMMED="${BRANCH_TRIMMED// }" echo "=== Git push auth probe (dry-run; no remote refs updated) ===" echo "Remote: ${REMOTE}" echo "Local HEAD: $(git rev-parse HEAD)" -echo "Probe refspec: HEAD:${PROBE_REF}" git remote -v -if ! git push --dry-run "${REMOTE}" "HEAD:${PROBE_REF}"; then +if [[ "${BUILDKITE:-}" == "true" ]] && [[ -z "${BRANCH_TRIMMED}" ]]; then + echo "ERROR: BRANCH must be set in Buildkite so the probe can dry-run refs/heads/\${BRANCH}." >&2 + exit 1 +fi + +if [[ -n "${BRANCH_TRIMMED}" ]]; then + echo "Protected-ref probe: BRANCH=${BRANCH_TRIMMED} (same ref as bump_version.sh push target)" + echo "Fetching origin/${BRANCH_TRIMMED}..." + git fetch origin "${BRANCH_TRIMMED}" + echo "FETCH_HEAD: $(git rev-parse FETCH_HEAD)" + REFSPEC="FETCH_HEAD:refs/heads/${BRANCH_TRIMMED}" +else + echo "WARNING: BRANCH unset — using disposable refs/heads/ci/ probe only (weaker; see script header)." >&2 + PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" + REFSPEC="HEAD:${PROBE_REF}" + echo "Fallback probe refspec: ${REFSPEC}" +fi + +if ! git push --dry-run "${REMOTE}" "${REFSPEC}"; then echo "ERROR: git push --dry-run failed — check credentials and GitHub permissions for ${REMOTE}." >&2 exit 1 fi diff --git a/dev-tools/unittest/test_version_bump_validation.py b/dev-tools/unittest/test_version_bump_validation.py index 6108ddd4a..bb20fdc18 100644 --- a/dev-tools/unittest/test_version_bump_validation.py +++ b/dev-tools/unittest/test_version_bump_validation.py @@ -15,6 +15,7 @@ export VERSION_BUMP_GIT_INTEGRATION=1 export VERSION_BUMP_TEST_BRANCH=9.5 # MAJOR.MINOR branch that exists on origin + python3 -m pip install -r dev-tools/test-requirements.txt ./dev-tools/run_dev_tools_tests.sh Optional: ``VERSION_BUMP_SKIP_NEGATIVE_INTEGRATION=1`` to skip the negative @@ -56,7 +57,6 @@ def test_patch_ok_consecutive() -> None: current_version="9.5.0", new_version="9.5.1", branch="9.5", - workflow="patch", ) @@ -65,7 +65,6 @@ def test_patch_ok_noop_same_version() -> None: current_version="9.5.1", new_version="9.5.1", branch="9.5", - workflow="patch", ) @@ -75,64 +74,27 @@ def test_patch_rejects_skip() -> None: current_version="9.5.0", new_version="9.5.2", branch="9.5", - workflow="patch", ) -def test_patch_rejects_wrong_branch_minor() -> None: +def test_patch_rejects_wrong_release_branch() -> None: with pytest.raises(ValueError): vbu.validate_version_bump_params( current_version="9.5.0", new_version="9.5.1", branch="9.4", - workflow="patch", ) -def test_patch_rejects_minor_mismatch() -> None: +def test_patch_rejects_major_minor_mismatch() -> None: with pytest.raises(ValueError): vbu.validate_version_bump_params( current_version="9.4.9", new_version="9.5.1", branch="9.5", - workflow="patch", ) -def test_minor_ok() -> None: - vbu.validate_version_bump_params( - current_version="9.4.12", - new_version="9.5.0", - branch="9.5", - workflow="minor", - ) - - -def test_minor_rejects_patch_not_zero() -> None: - with pytest.raises(ValueError): - vbu.validate_version_bump_params( - current_version="9.4.12", - new_version="9.5.1", - branch="9.5", - workflow="minor", - ) - - -def test_minor_rejects_wrong_increment() -> None: - with pytest.raises(ValueError): - vbu.validate_version_bump_params( - current_version="9.4.12", - new_version="9.6.0", - branch="9.6", - workflow="minor", - ) - - -def test_invalid_workflow() -> None: - with pytest.raises(ValueError): - vbu.validate_workflow_name("major") - - def test_cli_validate_patch_ok() -> None: rc = subprocess.call( [ @@ -145,8 +107,6 @@ def test_cli_validate_patch_ok() -> None: "9.5.1", "--branch", "9.5", - "--workflow", - "patch", ], cwd=str(_REPO_ROOT), stdout=subprocess.DEVNULL, @@ -167,8 +127,6 @@ def test_cli_validate_patch_negative() -> None: "9.5.2", "--branch", "9.5", - "--workflow", - "patch", ], cwd=str(_REPO_ROOT), stdout=subprocess.DEVNULL, @@ -177,28 +135,6 @@ def test_cli_validate_patch_negative() -> None: assert rc != 0 -def test_cli_validate_minor_ok() -> None: - rc = subprocess.call( - [ - sys.executable, - str(_MODULE), - "validate", - "--current", - "9.4.8", - "--new", - "9.5.0", - "--branch", - "9.5", - "--workflow", - "minor", - ], - cwd=str(_REPO_ROOT), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - assert rc == 0 - - @pytest.mark.skipif( not _VALIDATOR_SCRIPT.is_file(), reason="validate_version_bump_params.sh missing", @@ -219,6 +155,29 @@ def test_shell_skip_validation_env() -> None: assert out.returncode == 0, out.stderr + out.stdout +@pytest.mark.skipif( + not _VALIDATOR_SCRIPT.is_file(), + reason="validate_version_bump_params.sh missing", +) +def test_shell_rejects_non_patch_workflow() -> None: + """Upstream may send WORKFLOW=minor; fail before git fetch.""" + env = os.environ.copy() + env["WORKFLOW"] = "minor" + env["NEW_VERSION"] = "9.5.1" + env["BRANCH"] = "9.5" + env.pop("SKIP_VERSION_VALIDATION", None) + out = subprocess.run( + ["/bin/bash", str(_VALIDATOR_SCRIPT)], + cwd=str(_REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=5, + ) + assert out.returncode != 0, out.stderr + out.stdout + assert "WORKFLOW" in out.stderr or "WORKFLOW" in out.stdout + + def _integration_requested() -> bool: return os.environ.get("VERSION_BUMP_GIT_INTEGRATION") == "1" diff --git a/dev-tools/validate_version_bump_params.sh b/dev-tools/validate_version_bump_params.sh index 14bef29f8..0b9670693 100755 --- a/dev-tools/validate_version_bump_params.sh +++ b/dev-tools/validate_version_bump_params.sh @@ -9,14 +9,15 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. # -# Validates NEW_VERSION / BRANCH / WORKFLOW against elasticsearchVersion on the +# Validates NEW_VERSION / BRANCH against elasticsearchVersion on the # remote release branch before ml-cpp-version-bump runs bump_version.sh. # Semantic rules live in version_bump_validation.py (unit-tested). # # Environment: # NEW_VERSION — required target stack version (MAJOR.MINOR.PATCH), unless skipped # BRANCH — required release branch (e.g. 9.5), unless skipped -# WORKFLOW — patch (default) or minor +# WORKFLOW — optional; defaults to patch. If set by upstream automation, must be +# exactly "patch" (this pipeline does not support minor bumps). # SKIP_VERSION_VALIDATION — set to "true" to skip (emergency override only) # PYTHON — interpreter (default: python3) @@ -37,12 +38,20 @@ fi : "${BRANCH:?BRANCH must be set}" WORKFLOW="${WORKFLOW:-patch}" +if [[ "$WORKFLOW" != "patch" ]]; then + echo "ERROR: WORKFLOW must be \"patch\" for this pipeline, got \"${WORKFLOW}\"" >&2 + exit 1 +fi -echo "=== Version bump validation ===" +echo "=== Version bump validation (patch) ===" echo "WORKFLOW: ${WORKFLOW}" echo "NEW_VERSION: ${NEW_VERSION}" echo "BRANCH: ${BRANCH}" +# Patch-only pipeline (no WORKFLOW=minor): consecutive patch on this release +# branch. Current version is read from origin/${BRANCH} by design — there is no +# minor-line bump mode in dev-tools/version_bump_validation.py or this pipeline. + echo "Fetching origin/${BRANCH}..." git fetch origin "$BRANCH" @@ -51,8 +60,10 @@ if ! git cat-file -e FETCH_HEAD:gradle.properties 2>/dev/null; then exit 1 fi +# Allow empty result: with pipefail, grep exits 1 when there is no match, which +# would abort the substitution before the explicit empty check below. CURRENT_VERSION=$( - git show FETCH_HEAD:gradle.properties | grep '^elasticsearchVersion=' | head -1 | cut -d= -f2 | tr -d '[:space:]' + git show FETCH_HEAD:gradle.properties | grep '^elasticsearchVersion=' | head -1 | cut -d= -f2 | tr -d '[:space:]' || true ) if [[ -z "$CURRENT_VERSION" ]]; then @@ -65,5 +76,4 @@ echo "Current version on origin/${BRANCH}: ${CURRENT_VERSION}" exec "$PYTHON" "$VALIDATION_PY" validate-and-report \ --current "$CURRENT_VERSION" \ --new "$NEW_VERSION" \ - --branch "$BRANCH" \ - --workflow "$WORKFLOW" + --branch "$BRANCH" diff --git a/dev-tools/version_bump_validation.py b/dev-tools/version_bump_validation.py index f289262a7..8d19300ed 100644 --- a/dev-tools/version_bump_validation.py +++ b/dev-tools/version_bump_validation.py @@ -8,14 +8,16 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. # -"""Rules for ml-cpp release version bump parameters (Buildkite / release-eng). +"""Rules for ml-cpp patch release version bump parameters (Buildkite / release-eng). Used by dev-tools/validate_version_bump_params.sh and unit-tested under dev-tools/unittest/. -Run tests from ``dev-tools/``: +Run tests from repo root (install dev-tools test deps first, see +``dev-tools/run_dev_tools_tests.sh``): - ./run_dev_tools_tests.sh + python3 -m pip install -r dev-tools/test-requirements.txt + ./dev-tools/run_dev_tools_tests.sh Optional git integration (real ``git fetch`` + shell validator): set ``VERSION_BUMP_GIT_INTEGRATION=1`` and ``VERSION_BUMP_TEST_BRANCH=MAJOR.MINOR``. @@ -47,26 +49,16 @@ def parse_release_branch(branch: str) -> Optional[Tuple[int, int]]: return (int(m.group(1)), int(m.group(2))) -def validate_workflow_name(workflow: str) -> None: - if workflow not in ("patch", "minor"): - raise ValueError( - f"WORKFLOW must be 'patch' or 'minor', got {workflow!r}" - ) - - def validate_version_bump_params( *, current_version: str, new_version: str, branch: str, - workflow: str, ) -> None: - """Validate release bump parameters. Raises ValueError on failure. + """Validate patch bump parameters. Raises ValueError on failure. When current_version == new_version, the bump is a no-op and always valid. """ - validate_workflow_name(workflow) - new_t = parse_semver(new_version) if new_t is None: raise ValueError( @@ -97,35 +89,17 @@ def validate_version_bump_params( if current_version.strip() == new_version.strip(): return - if workflow == "patch": - if cur_major != new_major or cur_minor != new_minor: - raise ValueError( - "patch bump requires same MAJOR.MINOR as current " - f"({cur_major}.{cur_minor} vs {new_major}.{new_minor})" - ) - expected_patch = cur_patch + 1 - if new_patch != expected_patch: - raise ValueError( - "patch workflow expects NEW_VERSION patch = current patch + 1 " - f"({current_version} → {new_major}.{new_minor}.{expected_patch}), " - f"got {new_version}" - ) - return - - # minor - if new_patch != 0: - raise ValueError( - f"minor workflow expects NEW_VERSION with PATCH=0, got {new_version!r}" - ) - if cur_major != new_major: + if cur_major != new_major or cur_minor != new_minor: raise ValueError( - f"minor bump must keep the same MAJOR ({cur_major} vs {new_major})" + "patch bump requires same MAJOR.MINOR as current " + f"({cur_major}.{cur_minor} vs {new_major}.{new_minor})" ) - expected_minor = cur_minor + 1 - if new_minor != expected_minor: + expected_patch = cur_patch + 1 + if new_patch != expected_patch: raise ValueError( - "minor workflow expects MINOR = current minor + 1 " - f"({cur_minor} → {expected_minor}), got {new_minor}" + "patch bump expects NEW_VERSION patch = current patch + 1 " + f"({current_version} → {new_major}.{new_minor}.{expected_patch}), " + f"got {new_version}" ) @@ -135,7 +109,6 @@ def _cmd_validate(args: argparse.Namespace) -> int: current_version=args.current, new_version=args.new, branch=args.branch, - workflow=args.workflow, ) except ValueError as e: print(f"ERROR: {e}", file=sys.stderr) @@ -151,32 +124,24 @@ def _cmd_validate_and_report(args: argparse.Namespace) -> int: new = args.new.strip() if cur == new: print(f"OK: branch already at {new} — bump step will no-op.") - elif args.workflow == "patch": - print(f"OK: patch increment {cur} → {new}") else: - print(f"OK: minor increment {cur} → {new}") + print(f"OK: patch increment {cur} → {new}") return 0 def main() -> int: parser = argparse.ArgumentParser( - description="ml-cpp version bump parameter validation" + description="ml-cpp patch version bump parameter validation" ) sub = parser.add_subparsers(dest="command", required=True) p_val = sub.add_parser( "validate", - help="check current/new/branch/workflow (same rules as Buildkite)", + help="check current/new/branch (same rules as Buildkite)", ) p_val.add_argument("--current", required=True, help="elasticsearchVersion on branch") p_val.add_argument("--new", required=True, dest="new", help="NEW_VERSION") p_val.add_argument("--branch", required=True, help="BRANCH (MAJOR.MINOR)") - p_val.add_argument( - "--workflow", - required=True, - choices=("patch", "minor"), - help="WORKFLOW", - ) p_val.set_defaults(func=_cmd_validate) p_rep = sub.add_parser( @@ -186,7 +151,6 @@ def main() -> int: p_rep.add_argument("--current", required=True) p_rep.add_argument("--new", required=True, dest="new") p_rep.add_argument("--branch", required=True) - p_rep.add_argument("--workflow", required=True, choices=("patch", "minor")) p_rep.set_defaults(func=_cmd_validate_and_report) args = parser.parse_args() From 65afdf77bad88932af27a3fa5396f682b8d6cd96 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 14:32:03 +1200 Subject: [PATCH 18/32] [ML] Version bump via GitHub PR; optional DRA wait after merge - bump_version.sh: topic branch ci/ml-cpp-version-bump-* + push + REST PR create - create_github_pull_request.py: POST pulls using GITHUB_TOKEN/VAULT_GITHUB_TOKEN - job-version-bump: DRA json-watcher only when WAIT_FOR_DRA=true (post-merge run) Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 12 +-- dev-tools/bump_version.sh | 113 +++++++++++++++++------- dev-tools/create_github_pull_request.py | 87 ++++++++++++++++++ 3 files changed, 175 insertions(+), 37 deletions(-) create mode 100755 dev-tools/create_github_pull_request.py diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 6ce3832f4..e7c954b15 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -12,8 +12,10 @@ # It is intended to be triggered by the centralized release-eng pipeline. # # Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), -# bump version on BRANCH, then wait for staging and snapshot artifact JSON to -# publish NEW_VERSION. When DRY_RUN=true the DRA wait step is skipped (no push). +# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). +# Optional: set WAIT_FOR_DRA=true to run the json-watcher after the bump step +# (use a second pipeline run after the PR is merged; artifacts are not updated until then). +# When DRY_RUN=true the DRA wait step is skipped. import contextlib @@ -38,13 +40,13 @@ def json_watcher_plugin(url, expected_value): def dra_step(label, key, depends_on, plugins): - # Skip when DRY_RUN=true: bump_version.sh does not push, so artifact JSON - # never reaches NEW_VERSION and the json-watcher would time out (240m). + # Opt-in: bump opens a PR; DRA artifacts only advance after merge + publish. + # Skip when DRY_RUN=true (no topic-branch push). return { "label": label, "key": key, "depends_on": depends_on, - "if": 'build.env("DRY_RUN") != "true"', + "if": 'build.env("DRY_RUN") != "true" && build.env("WAIT_FOR_DRA") == "true"', "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index d197c226e..135a459da 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -12,14 +12,20 @@ # Automated patch version bump for the release-eng pipeline. # # Parameter checks run in dev-tools/validate_version_bump_params.sh before this -# step. After git pull, we re-run the same patch rules against the branch tip so a -# race (another bump / manual commit) cannot downgrade elasticsearchVersion. +# step. After git fetch, we re-run the same patch rules against the branch tip so a +# race (another bump / manual edits) cannot downgrade elasticsearchVersion. # -# Updates elasticsearchVersion in gradle.properties to NEW_VERSION on BRANCH, -# commits, and pushes. Does not modify .backportrc.json (reserved for future -# release automation). +# Creates a topic branch from origin/${BRANCH}, commits elasticsearchVersion in +# gradle.properties, pushes the topic branch to origin, and opens a GitHub pull +# request into ${BRANCH} (required by repository rulesets that disallow direct +# pushes). Does not modify .backportrc.json (reserved for future release automation). # -# Set DRY_RUN=true to perform all steps except git push. +# Environment: +# NEW_VERSION, BRANCH — required +# DRY_RUN — true to skip push and PR creation +# BUILDKITE_BUILD_NUMBER — appended to topic branch name for uniqueness +# VERSION_BUMP_TOPIC_BRANCH — optional override for topic branch name +# GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — for POST .../pulls (CI sets Vault token) # # Follows the same pattern as the Elasticsearch repo's automated # Lucene snapshot updates (.buildkite/scripts/lucene-snapshot/). @@ -29,6 +35,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PYTHON="${PYTHON:-python3}" VALIDATION_PY="${SCRIPT_DIR}/version_bump_validation.py" +CREATE_PR_PY="${SCRIPT_DIR}/create_github_pull_request.py" : "${NEW_VERSION:?NEW_VERSION must be set}" : "${BRANCH:?BRANCH must be set}" @@ -37,17 +44,32 @@ DRY_RUN="${DRY_RUN:-false}" GRADLE_PROPS="gradle.properties" if [ "$DRY_RUN" = "true" ]; then - echo "=== DRY RUN MODE — will not push ===" + echo "=== DRY RUN MODE — will not push or create PR ===" fi -git_push() { - local target_branch="$1" - if [ "$DRY_RUN" = "true" ]; then - echo " [DRY RUN] Would push $target_branch" - else - git push origin "$target_branch" - echo " Pushed $target_branch" +# Parse elastic/ml-cpp from origin (https://github.com/elastic/ml-cpp.git or git@...) +github_repo_slug() { + local url + url=$(git remote get-url origin 2>/dev/null || true) + if [[ "$url" =~ github\.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then + echo "${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + return 0 fi + echo "ERROR: could not parse owner/repo from git remote url: ${url:-empty}" >&2 + return 1 +} + +topic_branch_name() { + local tb + if [[ -n "${VERSION_BUMP_TOPIC_BRANCH:-}" ]]; then + echo "${VERSION_BUMP_TOPIC_BRANCH}" + return 0 + fi + tb="ci/ml-cpp-version-bump-${BRANCH}-${NEW_VERSION}" + if [[ -n "${BUILDKITE_BUILD_NUMBER:-}" ]]; then + tb="${tb}-bk${BUILDKITE_BUILD_NUMBER}" + fi + echo "$tb" } sed_inplace() { @@ -63,68 +85,95 @@ configure_git() { git config user.email 'infra-root+elasticsearchmachine@elastic.co' } -bump_version_on_branch() { +bump_version_via_pr() { local target_branch="$1" local target_version="$2" + local topic_branch current_version repo_slug pr_url + + topic_branch=$(topic_branch_name) + + git fetch origin "$target_branch" - git checkout "$target_branch" - git pull --ff-only origin "$target_branch" + # Topic branch starts at release-branch tip (same tree validation uses). + git checkout -B "$topic_branch" "origin/${target_branch}" - local current_version - # pipefail: grep exits 1 when there is no match — do not abort before the - # empty check below. current_version=$( grep '^elasticsearchVersion=' "$GRADLE_PROPS" | head -1 | cut -d= -f2 | tr -d '[:space:]' || true ) if [[ -z "$current_version" ]]; then - echo "ERROR: could not read elasticsearchVersion from ${GRADLE_PROPS} on ${target_branch}" >&2 + echo "ERROR: could not read elasticsearchVersion from ${GRADLE_PROPS} on origin/${target_branch}" >&2 exit 1 fi - # Revalidate against post-pull branch tip (race with other bumps / manual edits). if ! "$PYTHON" "$VALIDATION_PY" validate \ --current "$current_version" \ --new "$target_version" \ --branch "$target_branch" then - echo "ERROR: version bump does not match branch tip after pull (current=${current_version}, target=${target_version})." >&2 + echo "ERROR: version bump does not match branch tip after fetch (current=${current_version}, target=${target_version})." >&2 echo "Refusing to rewrite elasticsearchVersion — resolve manually if another automation advanced the branch." >&2 exit 1 fi if [ "$current_version" = "$target_version" ]; then - echo "Version on $target_branch is already $target_version — nothing to do" + echo "Version on origin/${target_branch} is already ${target_version} — nothing to do" return 0 fi - echo "Bumping version on $target_branch: $current_version → $target_version" + echo "Bumping version via PR branch ${topic_branch}: ${current_version} → ${target_version} (base ${target_branch})" sed_inplace "s/^elasticsearchVersion=.*/elasticsearchVersion=${target_version}/" "$GRADLE_PROPS" if ! grep -q "^elasticsearchVersion=${target_version}$" "$GRADLE_PROPS"; then - echo "ERROR: version update verification failed on $target_branch" + echo "ERROR: version update verification failed on ${topic_branch}" grep 'elasticsearchVersion' "$GRADLE_PROPS" exit 1 fi if git diff-index --quiet HEAD --; then - echo "No changes to commit on $target_branch (file unchanged after sed)" + echo "No changes to commit (file unchanged after sed)" return 0 fi configure_git git add "$GRADLE_PROPS" git commit -m "[ML] Bump version to ${target_version}" - git_push "$target_branch" + + if [ "$DRY_RUN" = "true" ]; then + echo " [DRY RUN] Would push origin ${topic_branch} and open PR into ${target_branch}" + return 0 + fi + + git push -u origin "$topic_branch" + echo " Pushed topic branch ${topic_branch}" + + repo_slug=$(github_repo_slug) || exit 1 + + pr_url=$( + "$PYTHON" "$CREATE_PR_PY" \ + --repo "$repo_slug" \ + --base "$target_branch" \ + --head "$topic_branch" \ + --title "[ML] Bump version to ${target_version}" \ + --body "Automated patch version bump for branch \`${target_branch}\`. + +| | | +| --- | --- | +| **elasticsearchVersion** | \`${current_version}\` → \`${target_version}\` | + +Please review and merge." + ) + echo " Opened pull request: ${pr_url}" } -echo "=== Patch version bump: $BRANCH → $NEW_VERSION ===" -bump_version_on_branch "$BRANCH" "$NEW_VERSION" +echo "=== Patch version bump (PR workflow): ${BRANCH} → ${NEW_VERSION} ===" +bump_version_via_pr "$BRANCH" "$NEW_VERSION" if [ "$DRY_RUN" = "true" ]; then echo "" echo "=== DRY RUN SUMMARY ===" - echo "Branch: $BRANCH" - echo "Version: $NEW_VERSION" + echo "Branch: $BRANCH" + echo "Version: $NEW_VERSION" + echo "Topic branch: $(topic_branch_name)" echo "Recent commits:" git log --oneline -3 fi diff --git a/dev-tools/create_github_pull_request.py b/dev-tools/create_github_pull_request.py new file mode 100755 index 000000000..f54a67d61 --- /dev/null +++ b/dev-tools/create_github_pull_request.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +"""Create a GitHub pull request using the REST API (no gh CLI required). + +Environment — one of: + GITHUB_TOKEN, VAULT_GITHUB_TOKEN, GH_TOKEN + +On success, prints the PR HTML URL to stdout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True, help="owner/repo (e.g. elastic/ml-cpp)") + parser.add_argument("--base", required=True, help="base branch name") + parser.add_argument("--head", required=True, help="head branch name") + parser.add_argument("--title", required=True) + parser.add_argument("--body", default="") + args = parser.parse_args() + + token = ( + os.environ.get("GITHUB_TOKEN") + or os.environ.get("VAULT_GITHUB_TOKEN") + or os.environ.get("GH_TOKEN") + ) + if not token: + print( + "ERROR: Set GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN", + file=sys.stderr, + ) + return 1 + + url = f"https://api.github.com/repos/{args.repo}/pulls" + payload = json.dumps( + { + "title": args.title, + "head": args.head, + "base": args.base, + "body": args.body, + } + ).encode("utf-8") + + req = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + "User-Agent": "ml-cpp-version-bump", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=120) as resp: + out = json.loads(resp.read().decode()) + html_url = out.get("html_url", "") + if html_url: + print(html_url) + return 0 + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + print(f"ERROR: GitHub API HTTP {e.code}: {detail}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From d7986896693f0d708d5ba3267bbe2c81564c644d Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 15:05:12 +1200 Subject: [PATCH 19/32] [ML] Restore DRA wait step in version bump pipeline (after PR merge) Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index e7c954b15..712b04ded 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -12,10 +12,11 @@ # It is intended to be triggered by the centralized release-eng pipeline. # # Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), -# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). -# Optional: set WAIT_FOR_DRA=true to run the json-watcher after the bump step -# (use a second pipeline run after the PR is merged; artifacts are not updated until then). -# When DRY_RUN=true the DRA wait step is skipped. +# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh), +# then poll staging/snapshot artifact JSON until NEW_VERSION appears. The PR must be +# merged (and snapshot/staging builds finish, typically ~1h) while the watcher runs; +# the step allows up to 240 minutes. When DRY_RUN=true the DRA wait step is skipped +# (no change merged, so artifacts would never advance). import contextlib @@ -40,13 +41,13 @@ def json_watcher_plugin(url, expected_value): def dra_step(label, key, depends_on, plugins): - # Opt-in: bump opens a PR; DRA artifacts only advance after merge + publish. - # Skip when DRY_RUN=true (no topic-branch push). + # Bump opens a PR; artifacts update after merge + builds. Watcher polls until match or timeout. + # Skip when DRY_RUN=true (no PR pushed). return { "label": label, "key": key, "depends_on": depends_on, - "if": 'build.env("DRY_RUN") != "true" && build.env("WAIT_FOR_DRA") == "true"', + "if": 'build.env("DRY_RUN") != "true"', "agents": { "image": WOLFI_IMAGE, "cpu": "250m", From f1a8e15b476b044f77a99ca4e54f85cd1fc3c297 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 15:36:49 +1200 Subject: [PATCH 20/32] [ML] Use GitHub CLI for version-bump PRs with gh auto-install Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 9 +- dev-tools/bump_version.sh | 44 +++++--- dev-tools/create_github_pull_request.py | 87 ---------------- dev-tools/create_github_pull_request.sh | 128 ++++++++++++++++++++++++ dev-tools/ensure_github_cli.sh | 87 ++++++++++++++++ 5 files changed, 253 insertions(+), 102 deletions(-) delete mode 100755 dev-tools/create_github_pull_request.py create mode 100755 dev-tools/create_github_pull_request.sh create mode 100755 dev-tools/ensure_github_cli.sh diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 712b04ded..3caa78165 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -12,8 +12,13 @@ # It is intended to be triggered by the centralized release-eng pipeline. # # Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), -# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh), -# then poll staging/snapshot artifact JSON until NEW_VERSION appears. The PR must be +# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). +# The bump step uses the GitHub CLI: gh pr create / gh pr merge via +# dev-tools/create_github_pull_request.sh, which runs dev-tools/ensure_github_cli.sh +# to install gh on Wolfi (apk) or via a Linux release tarball if needed. Uses image +# docker.elastic.co/release-eng/wolfi-build-essential-release-eng (outbound network for +# apk or tarball). Then poll staging/snapshot artifact JSON until NEW_VERSION appears. +# The PR must be # merged (and snapshot/staging builds finish, typically ~1h) while the watcher runs; # the step allows up to 240 minutes. When DRY_RUN=true the DRA wait step is skipped # (no change merged, so artifacts would never advance). diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 135a459da..d6bd30fb7 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -17,15 +17,19 @@ # # Creates a topic branch from origin/${BRANCH}, commits elasticsearchVersion in # gradle.properties, pushes the topic branch to origin, and opens a GitHub pull -# request into ${BRANCH} (required by repository rulesets that disallow direct -# pushes). Does not modify .backportrc.json (reserved for future release automation). +# request into ${BRANCH} via gh pr create (rulesets often disallow direct pushes). +# Optionally merges immediately with gh pr merge (unless VERSION_BUMP_NO_MERGE=true). +# Does not modify .backportrc.json (reserved for future release automation). # # Environment: # NEW_VERSION, BRANCH — required # DRY_RUN — true to skip push and PR creation # BUILDKITE_BUILD_NUMBER — appended to topic branch name for uniqueness # VERSION_BUMP_TOPIC_BRANCH — optional override for topic branch name -# GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — for POST .../pulls (CI sets Vault token) +# GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — auth for gh (CI sets Vault token) +# VERSION_BUMP_NO_MERGE — set to true to open PR only (no immediate gh pr merge) +# VERSION_BUMP_MERGE_METHOD — merge | squash | rebase (default: merge) +# gh install (apk/tarball): dev-tools/ensure_github_cli.sh via create_github_pull_request.sh # # Follows the same pattern as the Elasticsearch repo's automated # Lucene snapshot updates (.buildkite/scripts/lucene-snapshot/). @@ -35,7 +39,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PYTHON="${PYTHON:-python3}" VALIDATION_PY="${SCRIPT_DIR}/version_bump_validation.py" -CREATE_PR_PY="${SCRIPT_DIR}/create_github_pull_request.py" +CREATE_PR_SH="${SCRIPT_DIR}/create_github_pull_request.sh" : "${NEW_VERSION:?NEW_VERSION must be set}" : "${BRANCH:?BRANCH must be set}" @@ -148,21 +152,35 @@ bump_version_via_pr() { repo_slug=$(github_repo_slug) || exit 1 - pr_url=$( - "$PYTHON" "$CREATE_PR_PY" \ - --repo "$repo_slug" \ - --base "$target_branch" \ - --head "$topic_branch" \ - --title "[ML] Bump version to ${target_version}" \ - --body "Automated patch version bump for branch \`${target_branch}\`. + local pr_body + pr_body="$(cat < int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", required=True, help="owner/repo (e.g. elastic/ml-cpp)") - parser.add_argument("--base", required=True, help="base branch name") - parser.add_argument("--head", required=True, help="head branch name") - parser.add_argument("--title", required=True) - parser.add_argument("--body", default="") - args = parser.parse_args() - - token = ( - os.environ.get("GITHUB_TOKEN") - or os.environ.get("VAULT_GITHUB_TOKEN") - or os.environ.get("GH_TOKEN") - ) - if not token: - print( - "ERROR: Set GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN", - file=sys.stderr, - ) - return 1 - - url = f"https://api.github.com/repos/{args.repo}/pulls" - payload = json.dumps( - { - "title": args.title, - "head": args.head, - "base": args.base, - "body": args.body, - } - ).encode("utf-8") - - req = urllib.request.Request( - url, - data=payload, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - "User-Agent": "ml-cpp-version-bump", - }, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=120) as resp: - out = json.loads(resp.read().decode()) - html_url = out.get("html_url", "") - if html_url: - print(html_url) - return 0 - except urllib.error.HTTPError as e: - detail = e.read().decode(errors="replace") - print(f"ERROR: GitHub API HTTP {e.code}: {detail}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/dev-tools/create_github_pull_request.sh b/dev-tools/create_github_pull_request.sh new file mode 100755 index 000000000..1cf1b7ed7 --- /dev/null +++ b/dev-tools/create_github_pull_request.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Create a pull request (and optionally merge) using the GitHub CLI. +# +# Requires: gh (https://cli.github.com/) in PATH, authenticated via: +# GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN +# If gh is missing, dev-tools/ensure_github_cli.sh runs (Wolfi apk, else Linux +# tarball) unless SKIP_GH_AUTO_INSTALL=true. +# +# Usage: +# create_github_pull_request.sh --repo ORG/REPO --base BASE --head HEAD \ +# --title T --body B [--merge] [--merge-method merge|squash|rebase] +# +# On success, prints the PR URL to stdout (single line). Merge progress to stderr. +# +# Environment: +# VERSION_BUMP_MERGE_METHOD — default merge style when --merge is used (merge|squash|rebase) +# SKIP_GH_AUTO_INSTALL, GH_CLI_VERSION, GH_CLI_INSTALL_PREFIX — see ensure_github_cli.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v gh >/dev/null 2>&1; then + "${SCRIPT_DIR}/ensure_github_cli.sh" +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: GitHub CLI (gh) is not available; see dev-tools/ensure_github_cli.sh" >&2 + exit 1 +fi + +# gh honors GH_TOKEN; map Vault/standard env the same as other automation +if [[ -z "${GH_TOKEN:-}" ]]; then + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" + elif [[ -n "${VAULT_GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${VAULT_GITHUB_TOKEN}" + fi +fi + +if [[ -z "${GH_TOKEN:-}" ]]; then + echo "ERROR: Set GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN for gh auth." >&2 + exit 1 +fi + +REPO="" +BASE="" +HEAD_REF="" +TITLE="" +BODY="" +DO_MERGE="false" +MERGE_METHOD="${VERSION_BUMP_MERGE_METHOD:-merge}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO="$2" + shift 2 + ;; + --base) + BASE="$2" + shift 2 + ;; + --head) + HEAD_REF="$2" + shift 2 + ;; + --title) + TITLE="$2" + shift 2 + ;; + --body) + BODY="$2" + shift 2 + ;; + --merge) + DO_MERGE="true" + shift 1 + ;; + --merge-method) + MERGE_METHOD="$2" + shift 2 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$REPO" || -z "$BASE" || -z "$HEAD_REF" || -z "$TITLE" ]]; then + echo "ERROR: --repo, --base, --head, and --title are required." >&2 + exit 1 +fi + +case "$MERGE_METHOD" in + merge) MERGE_TYPE=(--merge) ;; + squash) MERGE_TYPE=(--squash) ;; + rebase) MERGE_TYPE=(--rebase) ;; + *) + echo "ERROR: invalid merge method: ${MERGE_METHOD}" >&2 + exit 1 + ;; +esac + +PR_URL=$(gh pr create \ + --repo "$REPO" \ + --base "$BASE" \ + --head "$HEAD_REF" \ + --title "$TITLE" \ + --body "$BODY") + +echo "$PR_URL" + +if [[ "$DO_MERGE" == "true" ]]; then + gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" --yes + echo "Merged: ${PR_URL}" >&2 +fi diff --git a/dev-tools/ensure_github_cli.sh b/dev-tools/ensure_github_cli.sh new file mode 100755 index 000000000..df0cbae2c --- /dev/null +++ b/dev-tools/ensure_github_cli.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Ensure the GitHub CLI (gh) is available on PATH. Used by automated PR flows +# (e.g. dev-tools/create_github_pull_request.sh) when the image does not +# pre-install gh (Wolfi: try apk; Linux: fall back to GitHub release tarball). +# +# Environment: +# SKIP_GH_AUTO_INSTALL — set to true to skip and exit non-zero if gh is missing +# GH_CLI_VERSION — pinned release for tarball fallback (default below) + +set -euo pipefail + +if command -v gh >/dev/null 2>&1; then + exit 0 +fi + +if [[ "${SKIP_GH_AUTO_INSTALL:-}" == "true" ]]; then + echo "ERROR: gh not found and SKIP_GH_AUTO_INSTALL=true" >&2 + exit 1 +fi + +echo "Installing GitHub CLI (gh)..." >&2 + +# Wolfi / Alpine-style images (ml-cpp version-bump uses release-eng Wolfi) +if command -v apk >/dev/null 2>&1; then + if apk add --no-cache gh 2>/dev/null || apk add --no-cache github-cli 2>/dev/null; then + command -v gh >/dev/null 2>&1 && exit 0 + fi +fi + +OS=$(uname -s) +ARCH=$(uname -m) +if [[ "$OS" != Linux ]]; then + echo "ERROR: gh not installed; on ${OS} install from https://cli.github.com/ (e.g. brew install gh)." >&2 + exit 1 +fi + +case "$ARCH" in + x86_64) GH_ARCH=amd64 ;; + aarch64 | arm64) GH_ARCH=arm64 ;; + *) + echo "ERROR: unsupported Linux machine type for gh tarball: ${ARCH}" >&2 + exit 1 + ;; +esac + +GH_CLI_VERSION="${GH_CLI_VERSION:-2.63.2}" +PREFIX="${GH_CLI_INSTALL_PREFIX:-/usr/local}" +BIN_DIR="${PREFIX}/bin" +if ! mkdir -p "$BIN_DIR" 2>/dev/null || [[ ! -w "$BIN_DIR" ]]; then + echo "ERROR: cannot write gh to ${BIN_DIR}; install gh manually or run as a user that can write there." >&2 + exit 1 +fi + +TMP=$(mktemp -d) +trap 'rm -rf "${TMP}"' EXIT + +URL="https://github.com/cli/cli/releases/download/v${GH_CLI_VERSION}/gh_${GH_CLI_VERSION}_linux_${GH_ARCH}.tar.gz" +if ! curl -fsSL "$URL" -o "${TMP}/gh.tgz"; then + echo "ERROR: failed to download gh ${GH_CLI_VERSION} from GitHub releases (set GH_CLI_VERSION?)." >&2 + exit 1 +fi + +tar -xzf "${TMP}/gh.tgz" -C "${TMP}" +GH_BIN=$(find "${TMP}" -path '*/bin/gh' -type f | head -1) +if [[ -z "${GH_BIN}" ]]; then + echo "ERROR: gh binary not found in release archive." >&2 + exit 1 +fi + +install -m 0755 "${GH_BIN}" "${BIN_DIR}/gh" +hash -r 2>/dev/null || true +echo "Installed gh to ${BIN_DIR}/gh" >&2 + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh still not on PATH after install (ensure ${BIN_DIR} is on PATH)." >&2 + exit 1 +fi From 1c71c5b9bd9e3f07f6f88dab8a025c9b76bf08d7 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 16:12:07 +1200 Subject: [PATCH 21/32] [ML] Fix gh pr merge for older Wolfi CLI (no --yes flag) Co-authored-by: Cursor --- dev-tools/create_github_pull_request.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev-tools/create_github_pull_request.sh b/dev-tools/create_github_pull_request.sh index 1cf1b7ed7..6c7600392 100755 --- a/dev-tools/create_github_pull_request.sh +++ b/dev-tools/create_github_pull_request.sh @@ -123,6 +123,8 @@ PR_URL=$(gh pr create \ echo "$PR_URL" if [[ "$DO_MERGE" == "true" ]]; then - gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" --yes + # Older packaged gh (e.g. Wolfi apk) does not support --yes on pr merge; rely on + # non-TTY / GH_PROMPT_DISABLED for unattended merges. + GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" echo "Merged: ${PR_URL}" >&2 fi From b656f6310fd9d446c38684e2721b2c3b3b7dd0b0 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 16:30:56 +1200 Subject: [PATCH 22/32] [ML] Default version-bump PR merge to squash (no merge commits on repo) Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 2 +- dev-tools/bump_version.sh | 4 ++-- dev-tools/create_github_pull_request.sh | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 3caa78165..9d56193d5 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -13,7 +13,7 @@ # # Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), # open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). -# The bump step uses the GitHub CLI: gh pr create / gh pr merge via +# The bump step uses the GitHub CLI: gh pr create / gh pr merge (default squash) via # dev-tools/create_github_pull_request.sh, which runs dev-tools/ensure_github_cli.sh # to install gh on Wolfi (apk) or via a Linux release tarball if needed. Uses image # docker.elastic.co/release-eng/wolfi-build-essential-release-eng (outbound network for diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index d6bd30fb7..ea94b03fb 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -28,7 +28,7 @@ # VERSION_BUMP_TOPIC_BRANCH — optional override for topic branch name # GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — auth for gh (CI sets Vault token) # VERSION_BUMP_NO_MERGE — set to true to open PR only (no immediate gh pr merge) -# VERSION_BUMP_MERGE_METHOD — merge | squash | rebase (default: merge) +# VERSION_BUMP_MERGE_METHOD — merge | squash | rebase (default: squash) # gh install (apk/tarball): dev-tools/ensure_github_cli.sh via create_github_pull_request.sh # # Follows the same pattern as the Elasticsearch repo's automated @@ -160,7 +160,7 @@ Automated patch version bump for branch \`${target_branch}\`. | --- | --- | | **elasticsearchVersion** | \`${current_version}\` → \`${target_version}\` | -Merged immediately by the version-bump pipeline via \`gh pr merge\` unless \`VERSION_BUMP_NO_MERGE=true\`. +Squash-merged immediately by the version-bump pipeline via \`gh pr merge --squash\` unless \`VERSION_BUMP_NO_MERGE=true\` (override style with \`VERSION_BUMP_MERGE_METHOD\`). EOF )" diff --git a/dev-tools/create_github_pull_request.sh b/dev-tools/create_github_pull_request.sh index 6c7600392..e1b57c117 100755 --- a/dev-tools/create_github_pull_request.sh +++ b/dev-tools/create_github_pull_request.sh @@ -23,7 +23,8 @@ # On success, prints the PR URL to stdout (single line). Merge progress to stderr. # # Environment: -# VERSION_BUMP_MERGE_METHOD — default merge style when --merge is used (merge|squash|rebase) +# VERSION_BUMP_MERGE_METHOD — merge style when --merge is used (merge|squash|rebase). +# Default squash — elastic/ml-cpp forbids merge commits on protected branches. # SKIP_GH_AUTO_INSTALL, GH_CLI_VERSION, GH_CLI_INSTALL_PREFIX — see ensure_github_cli.sh set -euo pipefail @@ -59,7 +60,7 @@ HEAD_REF="" TITLE="" BODY="" DO_MERGE="false" -MERGE_METHOD="${VERSION_BUMP_MERGE_METHOD:-merge}" +MERGE_METHOD="${VERSION_BUMP_MERGE_METHOD:-squash}" while [[ $# -gt 0 ]]; do case "$1" in From d8f1434837036b3ea036d1ba2eb8b75f1f270d96 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 5 May 2026 16:47:23 +1200 Subject: [PATCH 23/32] [ML] Opt-in gh pr merge --admin for protected-branch rule bypass Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 5 ++++- dev-tools/bump_version.sh | 1 + dev-tools/create_github_pull_request.sh | 8 +++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 9d56193d5..5db875b92 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -15,7 +15,10 @@ # open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). # The bump step uses the GitHub CLI: gh pr create / gh pr merge (default squash) via # dev-tools/create_github_pull_request.sh, which runs dev-tools/ensure_github_cli.sh -# to install gh on Wolfi (apk) or via a Linux release tarball if needed. Uses image +# to install gh on Wolfi (apk) or via a Linux release tarball if needed. +# If branch rules require reviews, set VERSION_BUMP_MERGE_ADMIN=true only when the +# Vault GitHub token may bypass rules, or exempt the pipeline actor in repo rules, +# or use VERSION_BUMP_NO_MERGE=true and merge manually. Uses image # docker.elastic.co/release-eng/wolfi-build-essential-release-eng (outbound network for # apk or tarball). Then poll staging/snapshot artifact JSON until NEW_VERSION appears. # The PR must be diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index ea94b03fb..1b9365e9f 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -29,6 +29,7 @@ # GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — auth for gh (CI sets Vault token) # VERSION_BUMP_NO_MERGE — set to true to open PR only (no immediate gh pr merge) # VERSION_BUMP_MERGE_METHOD — merge | squash | rebase (default: squash) +# VERSION_BUMP_MERGE_ADMIN — true to pass gh pr merge --admin (needs repo bypass rights) # gh install (apk/tarball): dev-tools/ensure_github_cli.sh via create_github_pull_request.sh # # Follows the same pattern as the Elasticsearch repo's automated diff --git a/dev-tools/create_github_pull_request.sh b/dev-tools/create_github_pull_request.sh index e1b57c117..24ca31389 100755 --- a/dev-tools/create_github_pull_request.sh +++ b/dev-tools/create_github_pull_request.sh @@ -25,6 +25,8 @@ # Environment: # VERSION_BUMP_MERGE_METHOD — merge style when --merge is used (merge|squash|rebase). # Default squash — elastic/ml-cpp forbids merge commits on protected branches. +# VERSION_BUMP_MERGE_ADMIN — set to true to add gh pr merge --admin (bypasses some rule +# violations only if the token identity has appropriate admin/bypass rights on the repo). # SKIP_GH_AUTO_INSTALL, GH_CLI_VERSION, GH_CLI_INSTALL_PREFIX — see ensure_github_cli.sh set -euo pipefail @@ -126,6 +128,10 @@ echo "$PR_URL" if [[ "$DO_MERGE" == "true" ]]; then # Older packaged gh (e.g. Wolfi apk) does not support --yes on pr merge; rely on # non-TTY / GH_PROMPT_DISABLED for unattended merges. - GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" + declare -a merge_admin=() + if [[ "${VERSION_BUMP_MERGE_ADMIN:-}" == "true" ]]; then + merge_admin+=(--admin) + fi + GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" "${merge_admin[@]}" echo "Merged: ${PR_URL}" >&2 fi From e8e8a2ecb00f1b651799c2809f698a0772c90cff Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 13:24:29 +1200 Subject: [PATCH 24/32] [ML] Enable GitHub auto-merge for version bump PRs Use gh pr merge --auto --squash after gh pr create (same pattern as backport workflow) unless VERSION_BUMP_MERGE_AUTO=false. - create_github_pull_request.sh: add --merge-auto; defer GH_TOKEN check until after CLI validation; keep --merge as immediate merge. - bump_version.sh: VERSION_BUMP_MERGE_AUTO=true selects --merge-auto. - job-version-bump.json.py: bump step defaults VERSION_BUMP_MERGE_AUTO=true (override when generating pipeline). - unittest: pipeline generator env + mutually exclusive merge flags. Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 14 ++-- dev-tools/bump_version.sh | 15 +++-- dev-tools/create_github_pull_request.sh | 58 ++++++++++------ .../test_job_version_bump_pipeline.py | 66 +++++++++++++++++++ 4 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 dev-tools/unittest/test_job_version_bump_pipeline.py diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 5db875b92..f5e431854 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -13,10 +13,12 @@ # # Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), # open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). -# The bump step uses the GitHub CLI: gh pr create / gh pr merge (default squash) via -# dev-tools/create_github_pull_request.sh, which runs dev-tools/ensure_github_cli.sh -# to install gh on Wolfi (apk) or via a Linux release tarball if needed. -# If branch rules require reviews, set VERSION_BUMP_MERGE_ADMIN=true only when the +# The bump step uses the GitHub CLI: gh pr create then gh pr merge --auto --squash +# (GitHub auto-merge when checks pass; same idea as backport workflow) via +# dev-tools/create_github_pull_request.sh --merge-auto, unless Buildkite env +# VERSION_BUMP_MERGE_AUTO=false for immediate squash merge (legacy). +# dev-tools/ensure_github_cli.sh installs gh on Wolfi (apk) or Linux tarball. +# If branch rules block auto-merge, set VERSION_BUMP_MERGE_ADMIN=true only when the # Vault GitHub token may bypass rules, or exempt the pipeline actor in repo rules, # or use VERSION_BUMP_NO_MERGE=true and merge manually. Uses image # docker.elastic.co/release-eng/wolfi-build-essential-release-eng (outbound network for @@ -120,6 +122,10 @@ def main(): "cpu": "250m", "memory": "512Mi", }, + "env": { + # Align with backport auto-merge (.github/workflows/backport.yml): no human merge click. + "VERSION_BUMP_MERGE_AUTO": os.environ.get("VERSION_BUMP_MERGE_AUTO", "true"), + }, "command": [ "dev-tools/bump_version.sh", ], diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 1b9365e9f..51099db78 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -18,7 +18,9 @@ # Creates a topic branch from origin/${BRANCH}, commits elasticsearchVersion in # gradle.properties, pushes the topic branch to origin, and opens a GitHub pull # request into ${BRANCH} via gh pr create (rulesets often disallow direct pushes). -# Optionally merges immediately with gh pr merge (unless VERSION_BUMP_NO_MERGE=true). +# Optionally merges after PR creation unless VERSION_BUMP_NO_MERGE=true: +# VERSION_BUMP_MERGE_AUTO=true — gh pr merge --auto --squash (merge when checks pass; default for CI pipeline) +# else — immediate gh pr merge --squash (legacy / escape hatch) # Does not modify .backportrc.json (reserved for future release automation). # # Environment: @@ -27,7 +29,8 @@ # BUILDKITE_BUILD_NUMBER — appended to topic branch name for uniqueness # VERSION_BUMP_TOPIC_BRANCH — optional override for topic branch name # GITHUB_TOKEN / VAULT_GITHUB_TOKEN / GH_TOKEN — auth for gh (CI sets Vault token) -# VERSION_BUMP_NO_MERGE — set to true to open PR only (no immediate gh pr merge) +# VERSION_BUMP_NO_MERGE — set to true to open PR only (no merge / auto-merge step) +# VERSION_BUMP_MERGE_AUTO — true: enable GitHub auto-merge (--auto --squash); false/unset with merge: immediate squash # VERSION_BUMP_MERGE_METHOD — merge | squash | rebase (default: squash) # VERSION_BUMP_MERGE_ADMIN — true to pass gh pr merge --admin (needs repo bypass rights) # gh install (apk/tarball): dev-tools/ensure_github_cli.sh via create_github_pull_request.sh @@ -161,7 +164,7 @@ Automated patch version bump for branch \`${target_branch}\`. | --- | --- | | **elasticsearchVersion** | \`${current_version}\` → \`${target_version}\` | -Squash-merged immediately by the version-bump pipeline via \`gh pr merge --squash\` unless \`VERSION_BUMP_NO_MERGE=true\` (override style with \`VERSION_BUMP_MERGE_METHOD\`). +When merging is enabled (\`VERSION_BUMP_NO_MERGE\` not true): **auto-merge** (\`gh pr merge --auto --squash\`) if \`VERSION_BUMP_MERGE_AUTO=true\`, otherwise **immediate squash** (\`gh pr merge --squash\`). Override merge style with \`VERSION_BUMP_MERGE_METHOD\`. EOF )" @@ -174,7 +177,11 @@ EOF --body "$pr_body" ) if [[ "${VERSION_BUMP_NO_MERGE:-}" != "true" ]]; then - pr_cmd+=(--merge) + if [[ "${VERSION_BUMP_MERGE_AUTO:-}" == "true" ]]; then + pr_cmd+=(--merge-auto) + else + pr_cmd+=(--merge) + fi fi if [[ -n "${VERSION_BUMP_MERGE_METHOD:-}" ]]; then pr_cmd+=(--merge-method "${VERSION_BUMP_MERGE_METHOD}") diff --git a/dev-tools/create_github_pull_request.sh b/dev-tools/create_github_pull_request.sh index 24ca31389..23242df56 100755 --- a/dev-tools/create_github_pull_request.sh +++ b/dev-tools/create_github_pull_request.sh @@ -18,12 +18,17 @@ # # Usage: # create_github_pull_request.sh --repo ORG/REPO --base BASE --head HEAD \ -# --title T --body B [--merge] [--merge-method merge|squash|rebase] +# --title T --body B [--merge | --merge-auto] [--merge-method merge|squash|rebase] # # On success, prints the PR URL to stdout (single line). Merge progress to stderr. # +# --merge Squash/merge/rebase immediately after create (subject to branch rules). +# --merge-auto Enable GitHub auto-merge with the chosen merge method (same pattern as +# backport workflow: gh pr merge --auto --squash). Merge occurs when +# required checks pass; no human click needed if policies allow the bot. +# # Environment: -# VERSION_BUMP_MERGE_METHOD — merge style when --merge is used (merge|squash|rebase). +# VERSION_BUMP_MERGE_METHOD — merge style for --merge / --merge-auto (merge|squash|rebase). # Default squash — elastic/ml-cpp forbids merge commits on protected branches. # VERSION_BUMP_MERGE_ADMIN — set to true to add gh pr merge --admin (bypasses some rule # violations only if the token identity has appropriate admin/bypass rights on the repo). @@ -42,26 +47,13 @@ if ! command -v gh >/dev/null 2>&1; then exit 1 fi -# gh honors GH_TOKEN; map Vault/standard env the same as other automation -if [[ -z "${GH_TOKEN:-}" ]]; then - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - export GH_TOKEN="${GITHUB_TOKEN}" - elif [[ -n "${VAULT_GITHUB_TOKEN:-}" ]]; then - export GH_TOKEN="${VAULT_GITHUB_TOKEN}" - fi -fi - -if [[ -z "${GH_TOKEN:-}" ]]; then - echo "ERROR: Set GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN for gh auth." >&2 - exit 1 -fi - REPO="" BASE="" HEAD_REF="" TITLE="" BODY="" DO_MERGE="false" +DO_MERGE_AUTO="false" MERGE_METHOD="${VERSION_BUMP_MERGE_METHOD:-squash}" while [[ $# -gt 0 ]]; do @@ -90,6 +82,10 @@ while [[ $# -gt 0 ]]; do DO_MERGE="true" shift 1 ;; + --merge-auto) + DO_MERGE_AUTO="true" + shift 1 + ;; --merge-method) MERGE_METHOD="$2" shift 2 @@ -101,6 +97,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$DO_MERGE" == "true" && "$DO_MERGE_AUTO" == "true" ]]; then + echo "ERROR: use only one of --merge or --merge-auto." >&2 + exit 1 +fi + if [[ -z "$REPO" || -z "$BASE" || -z "$HEAD_REF" || -z "$TITLE" ]]; then echo "ERROR: --repo, --base, --head, and --title are required." >&2 exit 1 @@ -116,6 +117,20 @@ case "$MERGE_METHOD" in ;; esac +# gh honors GH_TOKEN; validate after CLI args so invalid flag combinations fail without secrets. +if [[ -z "${GH_TOKEN:-}" ]]; then + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" + elif [[ -n "${VAULT_GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${VAULT_GITHUB_TOKEN}" + fi +fi + +if [[ -z "${GH_TOKEN:-}" ]]; then + echo "ERROR: Set GITHUB_TOKEN, VAULT_GITHUB_TOKEN, or GH_TOKEN for gh auth." >&2 + exit 1 +fi + PR_URL=$(gh pr create \ --repo "$REPO" \ --base "$BASE" \ @@ -125,13 +140,18 @@ PR_URL=$(gh pr create \ echo "$PR_URL" -if [[ "$DO_MERGE" == "true" ]]; then +if [[ "$DO_MERGE" == "true" || "$DO_MERGE_AUTO" == "true" ]]; then # Older packaged gh (e.g. Wolfi apk) does not support --yes on pr merge; rely on # non-TTY / GH_PROMPT_DISABLED for unattended merges. declare -a merge_admin=() if [[ "${VERSION_BUMP_MERGE_ADMIN:-}" == "true" ]]; then merge_admin+=(--admin) fi - GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" "${merge_admin[@]}" - echo "Merged: ${PR_URL}" >&2 + if [[ "$DO_MERGE_AUTO" == "true" ]]; then + GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" --auto "${MERGE_TYPE[@]}" "${merge_admin[@]}" + echo "Enabled auto-merge: ${PR_URL}" >&2 + else + GH_PROMPT_DISABLED=1 gh pr merge "$PR_URL" "${MERGE_TYPE[@]}" "${merge_admin[@]}" + echo "Merged: ${PR_URL}" >&2 + fi fi diff --git a/dev-tools/unittest/test_job_version_bump_pipeline.py b/dev-tools/unittest/test_job_version_bump_pipeline.py new file mode 100644 index 000000000..d6f22fee8 --- /dev/null +++ b/dev-tools/unittest/test_job_version_bump_pipeline.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. + +"""Tests for .buildkite/job-version-bump.json.py pipeline generator.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PIPELINE_SCRIPT = _REPO_ROOT / ".buildkite" / "job-version-bump.json.py" + + +def _run_pipeline_generator(extra_env: dict[str, str] | None = None) -> dict: + """Run the generator with a clean env (drops inherited VERSION_BUMP_MERGE_AUTO unless set).""" + env = os.environ.copy() + env.pop("VERSION_BUMP_MERGE_AUTO", None) + if extra_env: + env.update(extra_env) + out = subprocess.check_output( + [sys.executable, str(_PIPELINE_SCRIPT)], + cwd=str(_REPO_ROOT), + env=env, + text=True, + ) + return json.loads(out) + + +def _bump_step(pipeline: dict) -> dict: + steps = pipeline["steps"] + bump = next(s for s in steps if s.get("key") == "bump-version") + return bump + + +def test_bump_step_defaults_merge_auto_true() -> None: + pipeline = _run_pipeline_generator() + assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "true" + + +def test_bump_step_respects_merge_auto_override_false() -> None: + pipeline = _run_pipeline_generator({"VERSION_BUMP_MERGE_AUTO": "false"}) + assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "false" + + +def test_mutually_exclusive_merge_flags_script() -> None: + """create_github_pull_request.sh rejects --merge and --merge-auto together.""" + script = _REPO_ROOT / "dev-tools" / "create_github_pull_request.sh" + proc = subprocess.run( + ["bash", str(script), "--repo", "r/r", "--base", "b", "--head", "h", "--title", "t", "--merge", "--merge-auto"], + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + assert "only one of --merge or --merge-auto" in proc.stderr From bb9a80bdb255a769e87bd651a557d0cbfa51aad7 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 13:42:50 +1200 Subject: [PATCH 25/32] [ML] Remove git push dry-run step from version-bump pipeline The probe targeted refs/heads/${BRANCH} while bump_version pushes ci/* topic branches only; fail-fast value is limited. Drop git_push_auth_probe.sh. Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 19 ++------ dev-tools/git_push_auth_probe.sh | 69 ----------------------------- 2 files changed, 3 insertions(+), 85 deletions(-) delete mode 100755 dev-tools/git_push_auth_probe.sh diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index f5e431854..0bce84b33 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -11,8 +11,8 @@ # This script generates JSON for the ml-cpp version bump pipeline. # It is intended to be triggered by the centralized release-eng pipeline. # -# Patch-only: validate NEW_VERSION/BRANCH, verify git push credentials (dry-run), -# open a PR that bumps elasticsearchVersion on BRANCH (see dev-tools/bump_version.sh). +# Patch-only: validate NEW_VERSION/BRANCH, open a PR that bumps elasticsearchVersion +# on BRANCH (see dev-tools/bump_version.sh). # The bump step uses the GitHub CLI: gh pr create then gh pr merge --auto --squash # (GitHub auto-merge when checks pass; same idea as backport workflow) via # dev-tools/create_github_pull_request.sh --merge-auto, unless Buildkite env @@ -91,23 +91,10 @@ def main(): "dev-tools/validate_version_bump_params.sh", ], }, - { - "label": "Verify git push credentials (dry-run)", - "key": "git-push-auth-probe", - "depends_on": "validate-version-bump", - "agents": { - "image": WOLFI_IMAGE, - "cpu": "250m", - "memory": "512Mi", - }, - "command": [ - "dev-tools/git_push_auth_probe.sh", - ], - }, { "label": "Queue a :slack: notification for the pipeline", "key": "queue-slack-notify", - "depends_on": "git-push-auth-probe", + "depends_on": "validate-version-bump", "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", diff --git a/dev-tools/git_push_auth_probe.sh b/dev-tools/git_push_auth_probe.sh deleted file mode 100755 index ec1fcad59..000000000 --- a/dev-tools/git_push_auth_probe.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -# -# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -# or more contributor license agreements. Licensed under the Elastic License -# 2.0 and the following additional limitation. Functionality enabled by the -# files subject to the Elastic License 2.0 may only be used in production when -# invoked by an Elasticsearch process with a license key installed that permits -# use of machine learning features. You may not use this file except in -# compliance with the Elastic License 2.0 and the foregoing additional -# limitation. -# -# Verifies that the checkout can authenticate for git push to origin, without -# updating any remote refs (uses "git push --dry-run"). -# -# Primary path (Buildkite sets BRANCH): dry-run FETCH_HEAD:refs/heads/${BRANCH} -# after fetching origin/${BRANCH} — same ref as dev-tools/bump_version.sh, so -# branch protection / rulesets on the release branch are exercised (not only a -# disposable ci/ ref). -# -# Fallback when BRANCH is unset (optional local use): disposable refs/heads/ci/ -# — weaker (does not prove permission on the real release branch). -# -# Intended for the ml-cpp-version-bump Buildkite pipeline (same agent + remotes -# as dev-tools/bump_version.sh). -# -# Environment: -# BRANCH — release branch (e.g. 9.5). Required when BUILDKITE=true (protected-ref -# probe); optional locally (falls back to refs/heads/ci/...). -# BUILDKITE_BUILD_NUMBER — used to uniquify the fallback ci/ probe ref -# (defaults to "local" when unset). -# GIT_REMOTE — remote name (default: origin). - -set -euo pipefail - -REMOTE="${GIT_REMOTE:-origin}" -BUILD_NUM="${BUILDKITE_BUILD_NUMBER:-local}" -# Collapse whitespace-only BRANCH (invalid for fetch / refspec). -BRANCH_TRIMMED="${BRANCH:-}" -BRANCH_TRIMMED="${BRANCH_TRIMMED// }" - -echo "=== Git push auth probe (dry-run; no remote refs updated) ===" -echo "Remote: ${REMOTE}" -echo "Local HEAD: $(git rev-parse HEAD)" -git remote -v - -if [[ "${BUILDKITE:-}" == "true" ]] && [[ -z "${BRANCH_TRIMMED}" ]]; then - echo "ERROR: BRANCH must be set in Buildkite so the probe can dry-run refs/heads/\${BRANCH}." >&2 - exit 1 -fi - -if [[ -n "${BRANCH_TRIMMED}" ]]; then - echo "Protected-ref probe: BRANCH=${BRANCH_TRIMMED} (same ref as bump_version.sh push target)" - echo "Fetching origin/${BRANCH_TRIMMED}..." - git fetch origin "${BRANCH_TRIMMED}" - echo "FETCH_HEAD: $(git rev-parse FETCH_HEAD)" - REFSPEC="FETCH_HEAD:refs/heads/${BRANCH_TRIMMED}" -else - echo "WARNING: BRANCH unset — using disposable refs/heads/ci/ probe only (weaker; see script header)." >&2 - PROBE_REF="refs/heads/ci/ml-cpp-bump-push-probe-${BUILD_NUM}" - REFSPEC="HEAD:${PROBE_REF}" - echo "Fallback probe refspec: ${REFSPEC}" -fi - -if ! git push --dry-run "${REMOTE}" "${REFSPEC}"; then - echo "ERROR: git push --dry-run failed — check credentials and GitHub permissions for ${REMOTE}." >&2 - exit 1 -fi - -echo "OK: git push --dry-run succeeded for ${REMOTE}." From c4404249c55f6b078108ed2ee0e6ddfc1e095fa6 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 14:43:47 +1200 Subject: [PATCH 26/32] [ML] Skip DRA wait when version bump is a no-op bump_version.sh sets Buildkite meta-data ml_cpp_version_bump_changed when a PR is opened (or DRY_RUN would). No-op runs keep it false. The fetch-dra-artifacts step only runs when DRY_RUN is not true and meta-data is true, avoiding a long wait when origin already matches NEW_VERSION. Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 12 ++++++++--- dev-tools/bump_version.sh | 21 +++++++++++++++++++ .../test_job_version_bump_pipeline.py | 12 +++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 0bce84b33..505d1b22c 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -26,7 +26,10 @@ # The PR must be # merged (and snapshot/staging builds finish, typically ~1h) while the watcher runs; # the step allows up to 240 minutes. When DRY_RUN=true the DRA wait step is skipped -# (no change merged, so artifacts would never advance). +# (no change merged, so artifacts would never advance). When bump_version.sh does not +# open a PR (branch already at NEW_VERSION), it sets meta-data ml_cpp_version_bump_changed=false +# and the DRA step is skipped so the pipeline does not wait 240 minutes for artifacts +# that will not advance. import contextlib @@ -52,12 +55,15 @@ def json_watcher_plugin(url, expected_value): def dra_step(label, key, depends_on, plugins): # Bump opens a PR; artifacts update after merge + builds. Watcher polls until match or timeout. - # Skip when DRY_RUN=true (no PR pushed). + # Skip when DRY_RUN=true (no PR pushed) or when bump_version.sh did not open a PR (no-op bump). return { "label": label, "key": key, "depends_on": depends_on, - "if": 'build.env("DRY_RUN") != "true"', + "if": ( + 'build.env("DRY_RUN") != "true" && ' + 'build.meta_data("ml_cpp_version_bump_changed") == "true"' + ), "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 51099db78..b5c9613bd 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -35,6 +35,9 @@ # VERSION_BUMP_MERGE_ADMIN — true to pass gh pr merge --admin (needs repo bypass rights) # gh install (apk/tarball): dev-tools/ensure_github_cli.sh via create_github_pull_request.sh # +# Buildkite (BUILDKITE=true): sets meta-data ml_cpp_version_bump_changed to true|false so +# the DRA wait step can skip when origin already has NEW_VERSION (no PR opened). +# # Follows the same pattern as the Elasticsearch repo's automated # Lucene snapshot updates (.buildkite/scripts/lucene-snapshot/). @@ -93,11 +96,27 @@ configure_git() { git config user.email 'infra-root+elasticsearchmachine@elastic.co' } +# Record whether this run actually opened a version-bump PR (for Buildkite DRA wait gating). +version_bump_set_buildkite_meta_changed() { + local changed="$1" + if [[ "${BUILDKITE:-}" != "true" ]]; then + return 0 + fi + if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "WARNING: BUILDKITE=true but buildkite-agent not in PATH; skipping meta-data ml_cpp_version_bump_changed=${changed}" >&2 + return 0 + fi + buildkite-agent meta-data set "ml_cpp_version_bump_changed" "$changed" +} + bump_version_via_pr() { local target_branch="$1" local target_version="$2" local topic_branch current_version repo_slug pr_url + # Default: no DRA wait unless we open a PR (or DRY_RUN simulates one). + version_bump_set_buildkite_meta_changed false + topic_branch=$(topic_branch_name) git fetch origin "$target_branch" @@ -148,6 +167,7 @@ bump_version_via_pr() { if [ "$DRY_RUN" = "true" ]; then echo " [DRY RUN] Would push origin ${topic_branch} and open PR into ${target_branch}" + version_bump_set_buildkite_meta_changed true return 0 fi @@ -189,6 +209,7 @@ EOF pr_url=$("${pr_cmd[@]}") echo " Pull request: ${pr_url}" + version_bump_set_buildkite_meta_changed true } echo "=== Patch version bump (PR workflow): ${BRANCH} → ${NEW_VERSION} ===" diff --git a/dev-tools/unittest/test_job_version_bump_pipeline.py b/dev-tools/unittest/test_job_version_bump_pipeline.py index d6f22fee8..5f99fc044 100644 --- a/dev-tools/unittest/test_job_version_bump_pipeline.py +++ b/dev-tools/unittest/test_job_version_bump_pipeline.py @@ -43,6 +43,11 @@ def _bump_step(pipeline: dict) -> dict: return bump +def _dra_step(pipeline: dict) -> dict: + steps = pipeline["steps"] + return next(s for s in steps if s.get("key") == "fetch-dra-artifacts") + + def test_bump_step_defaults_merge_auto_true() -> None: pipeline = _run_pipeline_generator() assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "true" @@ -53,6 +58,13 @@ def test_bump_step_respects_merge_auto_override_false() -> None: assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "false" +def test_dra_step_requires_bump_meta_and_not_dry_run() -> None: + pipeline = _run_pipeline_generator() + cond = _dra_step(pipeline)["if"] + assert 'build.env("DRY_RUN") != "true"' in cond + assert 'build.meta_data("ml_cpp_version_bump_changed") == "true"' in cond + + def test_mutually_exclusive_merge_flags_script() -> None: """create_github_pull_request.sh rejects --merge and --merge-auto together.""" script = _REPO_ROOT / "dev-tools" / "create_github_pull_request.sh" From de045a5d55a873cac3f3b0838b2d80f30787747b Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 14:50:42 +1200 Subject: [PATCH 27/32] [ML] Skip Slack and bump steps when validate detects no-op validate_version_bump_params.sh sets ml_cpp_version_bump_noop when origin already matches NEW_VERSION. Queue slack + bump steps use the same if as before for DRA (no-op meta). Bump depends on validate only so a skipped Slack step does not block bump. SKIP_VERSION_VALIDATION forces noop meta false so downstream still runs. Co-authored-by: Cursor --- .buildkite/job-version-bump.json.py | 21 ++++++++----- .../test_job_version_bump_pipeline.py | 13 ++++++++ dev-tools/validate_version_bump_params.sh | 30 ++++++++++++++++++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 505d1b22c..86aa12729 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -26,10 +26,10 @@ # The PR must be # merged (and snapshot/staging builds finish, typically ~1h) while the watcher runs; # the step allows up to 240 minutes. When DRY_RUN=true the DRA wait step is skipped -# (no change merged, so artifacts would never advance). When bump_version.sh does not -# open a PR (branch already at NEW_VERSION), it sets meta-data ml_cpp_version_bump_changed=false -# and the DRA step is skipped so the pipeline does not wait 240 minutes for artifacts -# that will not advance. +# (no change merged, so artifacts would never advance). +# validate_version_bump_params.sh sets meta-data ml_cpp_version_bump_noop when +# origin/BRANCH already equals NEW_VERSION — Slack, bump, and DRA steps are skipped +# (bump_version.sh also sets ml_cpp_version_bump_changed when a PR is opened, for DRA). import contextlib @@ -38,6 +38,9 @@ WOLFI_IMAGE = "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest" + +# Skip Slack + bump when validate detected no version change (already at NEW_VERSION). +IF_NOT_NOOP = 'build.meta_data("ml_cpp_version_bump_noop") != "true"' STAGING_URL = "https://artifacts-staging.elastic.co/ml-cpp/latest" SNAPSHOT_URL = "https://storage.googleapis.com/elastic-artifacts-snapshot/ml-cpp/latest" @@ -55,13 +58,13 @@ def json_watcher_plugin(url, expected_value): def dra_step(label, key, depends_on, plugins): # Bump opens a PR; artifacts update after merge + builds. Watcher polls until match or timeout. - # Skip when DRY_RUN=true (no PR pushed) or when bump_version.sh did not open a PR (no-op bump). + # Skip when DRY_RUN, validate no-op (no bump step), or bump did not open a PR. return { "label": label, "key": key, "depends_on": depends_on, "if": ( - 'build.env("DRY_RUN") != "true" && ' + f'{IF_NOT_NOOP} && build.env("DRY_RUN") != "true" && ' 'build.meta_data("ml_cpp_version_bump_changed") == "true"' ), "agents": { @@ -101,6 +104,7 @@ def main(): "label": "Queue a :slack: notification for the pipeline", "key": "queue-slack-notify", "depends_on": "validate-version-bump", + "if": IF_NOT_NOOP, "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", @@ -109,7 +113,10 @@ def main(): { "label": "Bump version to ${NEW_VERSION}", "key": "bump-version", - "depends_on": "queue-slack-notify", + # Do not depend on queue-slack-notify: if that step were skipped, bump would + # be skipped too. Order: both run after validate (may run in parallel). + "depends_on": "validate-version-bump", + "if": IF_NOT_NOOP, "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/dev-tools/unittest/test_job_version_bump_pipeline.py b/dev-tools/unittest/test_job_version_bump_pipeline.py index 5f99fc044..8ecadab25 100644 --- a/dev-tools/unittest/test_job_version_bump_pipeline.py +++ b/dev-tools/unittest/test_job_version_bump_pipeline.py @@ -48,6 +48,10 @@ def _dra_step(pipeline: dict) -> dict: return next(s for s in steps if s.get("key") == "fetch-dra-artifacts") +def _slack_step(pipeline: dict) -> dict: + return next(s for s in pipeline["steps"] if s.get("key") == "queue-slack-notify") + + def test_bump_step_defaults_merge_auto_true() -> None: pipeline = _run_pipeline_generator() assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "true" @@ -61,10 +65,19 @@ def test_bump_step_respects_merge_auto_override_false() -> None: def test_dra_step_requires_bump_meta_and_not_dry_run() -> None: pipeline = _run_pipeline_generator() cond = _dra_step(pipeline)["if"] + assert 'build.meta_data("ml_cpp_version_bump_noop") != "true"' in cond assert 'build.env("DRY_RUN") != "true"' in cond assert 'build.meta_data("ml_cpp_version_bump_changed") == "true"' in cond +def test_slack_and_bump_skip_when_validate_noop_meta() -> None: + pipeline = _run_pipeline_generator() + want = 'build.meta_data("ml_cpp_version_bump_noop") != "true"' + assert _slack_step(pipeline)["if"] == want + assert _bump_step(pipeline)["if"] == want + assert _bump_step(pipeline)["depends_on"] == "validate-version-bump" + + def test_mutually_exclusive_merge_flags_script() -> None: """create_github_pull_request.sh rejects --merge and --merge-auto together.""" script = _REPO_ROOT / "dev-tools" / "create_github_pull_request.sh" diff --git a/dev-tools/validate_version_bump_params.sh b/dev-tools/validate_version_bump_params.sh index 0b9670693..4830cd487 100755 --- a/dev-tools/validate_version_bump_params.sh +++ b/dev-tools/validate_version_bump_params.sh @@ -20,9 +20,24 @@ # exactly "patch" (this pipeline does not support minor bumps). # SKIP_VERSION_VALIDATION — set to "true" to skip (emergency override only) # PYTHON — interpreter (default: python3) +# +# Buildkite (BUILDKITE=true): sets meta-data ml_cpp_version_bump_noop to true when +# origin/BRANCH already has NEW_VERSION, so downstream Slack/bump steps are skipped. set -euo pipefail +version_bump_set_noop_meta() { + local noop="$1" + if [[ "${BUILDKITE:-}" != "true" ]]; then + return 0 + fi + if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "WARNING: BUILDKITE=true but buildkite-agent not in PATH; skipping meta-data ml_cpp_version_bump_noop=${noop}" >&2 + return 0 + fi + buildkite-agent meta-data set "ml_cpp_version_bump_noop" "$noop" +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PYTHON="${PYTHON:-python3}" VALIDATION_PY="${SCRIPT_DIR}/version_bump_validation.py" @@ -31,6 +46,7 @@ SKIP_VERSION_VALIDATION="${SKIP_VERSION_VALIDATION:-false}" if [[ "$SKIP_VERSION_VALIDATION" == "true" ]]; then echo "WARNING: SKIP_VERSION_VALIDATION=true — version increment checks skipped." >&2 + version_bump_set_noop_meta false exit 0 fi @@ -73,7 +89,19 @@ fi echo "Current version on origin/${BRANCH}: ${CURRENT_VERSION}" -exec "$PYTHON" "$VALIDATION_PY" validate-and-report \ +if ! "$PYTHON" "$VALIDATION_PY" validate-and-report \ --current "$CURRENT_VERSION" \ --new "$NEW_VERSION" \ --branch "$BRANCH" +then + exit 1 +fi + +# Match Python strip() semantics for equality (no-op → skip later pipeline steps). +cur_trim=$(echo "$CURRENT_VERSION" | tr -d '[:space:]') +new_trim=$(echo "$NEW_VERSION" | tr -d '[:space:]') +if [[ "$cur_trim" == "$new_trim" ]]; then + version_bump_set_noop_meta true +else + version_bump_set_noop_meta false +fi From cef9d9652f0df8a4d4db08c68e4c4338dd1d15a1 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 15:08:58 +1200 Subject: [PATCH 28/32] [ML] Fix version-bump pipeline: avoid build.meta_data in step if Co-authored-by: Cursor --- .buildkite/job-version-bump-phase2.json.py | 77 +++++++++++ .buildkite/job-version-bump.json.py | 120 ++---------------- .../test_job_version_bump_pipeline.py | 111 +++++++++++----- dev-tools/version_bump_upload_phase2.sh | 45 +++++++ dev-tools/wait_version_bump_dra.py | 108 ++++++++++++++++ 5 files changed, 318 insertions(+), 143 deletions(-) create mode 100755 .buildkite/job-version-bump-phase2.json.py create mode 100755 dev-tools/version_bump_upload_phase2.sh create mode 100755 dev-tools/wait_version_bump_dra.py diff --git a/.buildkite/job-version-bump-phase2.json.py b/.buildkite/job-version-bump-phase2.json.py new file mode 100755 index 000000000..da9a271fc --- /dev/null +++ b/.buildkite/job-version-bump-phase2.json.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Phase 2 of the ml-cpp version-bump pipeline (uploaded by +# dev-tools/version_bump_upload_phase2.sh after validate). Step-level `if` cannot +# use Buildkite meta-data; gating is done in that shell script instead. + +import contextlib +import json +import os + + +WOLFI_IMAGE = "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest" + + +def main(): + pipeline_steps = [ + { + "label": "Queue a :slack: notification for the pipeline", + "key": "queue-slack-notify", + "depends_on": "schedule-version-bump-follow-up", + "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", + "agents": { + "image": "python", + }, + }, + { + "label": "Bump version to ${NEW_VERSION}", + "key": "bump-version", + "depends_on": "queue-slack-notify", + "agents": { + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", + }, + "env": { + "VERSION_BUMP_MERGE_AUTO": os.environ.get("VERSION_BUMP_MERGE_AUTO", "true"), + }, + "command": [ + "dev-tools/bump_version.sh", + ], + }, + { + "label": "Fetch DRA Artifacts", + "key": "fetch-dra-artifacts", + "depends_on": "bump-version", + "agents": { + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", + "ephemeralStorage": "1Gi", + }, + "command": [ + "python3", + "dev-tools/wait_version_bump_dra.py", + ], + "timeout_in_minutes": 240, + "retry": { + "automatic": [{"exit_status": "*", "limit": 2}], + "manual": {"permit_on_passed": True}, + }, + }, + ] + + print(json.dumps({"steps": pipeline_steps}, indent=2)) + + +if __name__ == "__main__": + with contextlib.suppress(KeyboardInterrupt): + main() diff --git a/.buildkite/job-version-bump.json.py b/.buildkite/job-version-bump.json.py index 86aa12729..1ae79ac6a 100755 --- a/.buildkite/job-version-bump.json.py +++ b/.buildkite/job-version-bump.json.py @@ -8,82 +8,20 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. # -# This script generates JSON for the ml-cpp version bump pipeline. -# It is intended to be triggered by the centralized release-eng pipeline. +# Phase 1 of the ml-cpp version bump pipeline (dynamic upload from release-eng). # -# Patch-only: validate NEW_VERSION/BRANCH, open a PR that bumps elasticsearchVersion -# on BRANCH (see dev-tools/bump_version.sh). -# The bump step uses the GitHub CLI: gh pr create then gh pr merge --auto --squash -# (GitHub auto-merge when checks pass; same idea as backport workflow) via -# dev-tools/create_github_pull_request.sh --merge-auto, unless Buildkite env -# VERSION_BUMP_MERGE_AUTO=false for immediate squash merge (legacy). -# dev-tools/ensure_github_cli.sh installs gh on Wolfi (apk) or Linux tarball. -# If branch rules block auto-merge, set VERSION_BUMP_MERGE_ADMIN=true only when the -# Vault GitHub token may bypass rules, or exempt the pipeline actor in repo rules, -# or use VERSION_BUMP_NO_MERGE=true and merge manually. Uses image -# docker.elastic.co/release-eng/wolfi-build-essential-release-eng (outbound network for -# apk or tarball). Then poll staging/snapshot artifact JSON until NEW_VERSION appears. -# The PR must be -# merged (and snapshot/staging builds finish, typically ~1h) while the watcher runs; -# the step allows up to 240 minutes. When DRY_RUN=true the DRA wait step is skipped -# (no change merged, so artifacts would never advance). -# validate_version_bump_params.sh sets meta-data ml_cpp_version_bump_noop when -# origin/BRANCH already equals NEW_VERSION — Slack, bump, and DRA steps are skipped -# (bump_version.sh also sets ml_cpp_version_bump_changed when a PR is opened, for DRA). - +# Buildkite step `if` expressions cannot use build meta-data (see +# https://buildkite.com/docs/pipelines/conditionals ). validate_version_bump_params.sh +# sets ml_cpp_version_bump_noop when origin already matches NEW_VERSION; phase 2 +# (Slack, bump, DRA wait) is uploaded only when needed by +# dev-tools/version_bump_upload_phase2.sh. import contextlib import json -import os WOLFI_IMAGE = "docker.elastic.co/release-eng/wolfi-build-essential-release-eng:latest" -# Skip Slack + bump when validate detected no version change (already at NEW_VERSION). -IF_NOT_NOOP = 'build.meta_data("ml_cpp_version_bump_noop") != "true"' -STAGING_URL = "https://artifacts-staging.elastic.co/ml-cpp/latest" -SNAPSHOT_URL = "https://storage.googleapis.com/elastic-artifacts-snapshot/ml-cpp/latest" - - -def json_watcher_plugin(url, expected_value): - return { - "elastic/json-watcher#v1.0.0": { - "url": url, - "field": ".version", - "expected_value": expected_value, - "polling_interval": "30", - } - } - - -def dra_step(label, key, depends_on, plugins): - # Bump opens a PR; artifacts update after merge + builds. Watcher polls until match or timeout. - # Skip when DRY_RUN, validate no-op (no bump step), or bump did not open a PR. - return { - "label": label, - "key": key, - "depends_on": depends_on, - "if": ( - f'{IF_NOT_NOOP} && build.env("DRY_RUN") != "true" && ' - 'build.meta_data("ml_cpp_version_bump_changed") == "true"' - ), - "agents": { - "image": WOLFI_IMAGE, - "cpu": "250m", - "memory": "512Mi", - "ephemeralStorage": "1Gi", - }, - "command": [ - 'echo "Waiting for DRA artifacts..."', - ], - "timeout_in_minutes": 240, - "retry": { - "automatic": [{"exit_status": "*", "limit": 2}], - "manual": {"permit_on_passed": True}, - }, - "plugins": plugins, - } - def main(): pipeline_steps = [ @@ -101,57 +39,19 @@ def main(): ], }, { - "label": "Queue a :slack: notification for the pipeline", - "key": "queue-slack-notify", + "label": "Schedule version bump follow-up steps", + "key": "schedule-version-bump-follow-up", "depends_on": "validate-version-bump", - "if": IF_NOT_NOOP, - "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", "agents": { "image": "python", }, - }, - { - "label": "Bump version to ${NEW_VERSION}", - "key": "bump-version", - # Do not depend on queue-slack-notify: if that step were skipped, bump would - # be skipped too. Order: both run after validate (may run in parallel). - "depends_on": "validate-version-bump", - "if": IF_NOT_NOOP, - "agents": { - "image": WOLFI_IMAGE, - "cpu": "250m", - "memory": "512Mi", - }, - "env": { - # Align with backport auto-merge (.github/workflows/backport.yml): no human merge click. - "VERSION_BUMP_MERGE_AUTO": os.environ.get("VERSION_BUMP_MERGE_AUTO", "true"), - }, "command": [ - "dev-tools/bump_version.sh", + "dev-tools/version_bump_upload_phase2.sh", ], }, - dra_step( - label="Fetch DRA Artifacts", - key="fetch-dra-artifacts", - depends_on="bump-version", - plugins=[ - json_watcher_plugin( - f"{STAGING_URL}/${{BRANCH}}.json", - "${NEW_VERSION}", - ), - json_watcher_plugin( - f"{SNAPSHOT_URL}/${{BRANCH}}.json", - "${NEW_VERSION}-SNAPSHOT", - ), - ], - ), ] - pipeline = { - "steps": pipeline_steps, - } - - print(json.dumps(pipeline, indent=2)) + print(json.dumps({"steps": pipeline_steps}, indent=2)) if __name__ == "__main__": diff --git a/dev-tools/unittest/test_job_version_bump_pipeline.py b/dev-tools/unittest/test_job_version_bump_pipeline.py index 8ecadab25..499f0a25b 100644 --- a/dev-tools/unittest/test_job_version_bump_pipeline.py +++ b/dev-tools/unittest/test_job_version_bump_pipeline.py @@ -8,7 +8,7 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. -"""Tests for .buildkite/job-version-bump.json.py pipeline generator.""" +"""Tests for .buildkite/job-version-bump*.json.py pipeline generators.""" from __future__ import annotations @@ -19,17 +19,17 @@ from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[2] -_PIPELINE_SCRIPT = _REPO_ROOT / ".buildkite" / "job-version-bump.json.py" +_PIPELINE_PHASE1 = _REPO_ROOT / ".buildkite" / "job-version-bump.json.py" +_PIPELINE_PHASE2 = _REPO_ROOT / ".buildkite" / "job-version-bump-phase2.json.py" -def _run_pipeline_generator(extra_env: dict[str, str] | None = None) -> dict: - """Run the generator with a clean env (drops inherited VERSION_BUMP_MERGE_AUTO unless set).""" +def _run_phase1(extra_env: dict[str, str] | None = None) -> dict: env = os.environ.copy() env.pop("VERSION_BUMP_MERGE_AUTO", None) if extra_env: env.update(extra_env) out = subprocess.check_output( - [sys.executable, str(_PIPELINE_SCRIPT)], + [sys.executable, str(_PIPELINE_PHASE1)], cwd=str(_REPO_ROOT), env=env, text=True, @@ -37,52 +37,97 @@ def _run_pipeline_generator(extra_env: dict[str, str] | None = None) -> dict: return json.loads(out) -def _bump_step(pipeline: dict) -> dict: - steps = pipeline["steps"] - bump = next(s for s in steps if s.get("key") == "bump-version") - return bump +def _run_phase2(extra_env: dict[str, str] | None = None) -> dict: + env = os.environ.copy() + env.pop("VERSION_BUMP_MERGE_AUTO", None) + if extra_env: + env.update(extra_env) + out = subprocess.check_output( + [sys.executable, str(_PIPELINE_PHASE2)], + cwd=str(_REPO_ROOT), + env=env, + text=True, + ) + return json.loads(out) + + +def _step_by_key(pipeline: dict, key: str) -> dict: + return next(s for s in pipeline["steps"] if s.get("key") == key) + + +def test_phase1_has_validate_and_schedule_only() -> None: + pipeline = _run_phase1() + keys = [s.get("key") for s in pipeline["steps"]] + assert keys == ["validate-version-bump", "schedule-version-bump-follow-up"] + + +def test_phase1_has_no_step_if_using_meta_data() -> None: + """Buildkite rejects build.meta_data in step if expressions at pipeline upload.""" + pipeline = _run_phase1() + for step in pipeline["steps"]: + cond = step.get("if") + if cond is None: + continue + assert "build.meta_data" not in cond -def _dra_step(pipeline: dict) -> dict: - steps = pipeline["steps"] - return next(s for s in steps if s.get("key") == "fetch-dra-artifacts") +def test_phase1_schedule_depends_on_validate() -> None: + pipeline = _run_phase1() + sched = _step_by_key(pipeline, "schedule-version-bump-follow-up") + assert sched["depends_on"] == "validate-version-bump" + assert sched["command"] == ["dev-tools/version_bump_upload_phase2.sh"] -def _slack_step(pipeline: dict) -> dict: - return next(s for s in pipeline["steps"] if s.get("key") == "queue-slack-notify") +def test_phase2_bump_defaults_merge_auto_true() -> None: + pipeline = _run_phase2() + bump = _step_by_key(pipeline, "bump-version") + assert bump["env"]["VERSION_BUMP_MERGE_AUTO"] == "true" -def test_bump_step_defaults_merge_auto_true() -> None: - pipeline = _run_pipeline_generator() - assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "true" +def test_phase2_bump_respects_merge_auto_override_false() -> None: + pipeline = _run_phase2({"VERSION_BUMP_MERGE_AUTO": "false"}) + bump = _step_by_key(pipeline, "bump-version") + assert bump["env"]["VERSION_BUMP_MERGE_AUTO"] == "false" -def test_bump_step_respects_merge_auto_override_false() -> None: - pipeline = _run_pipeline_generator({"VERSION_BUMP_MERGE_AUTO": "false"}) - assert _bump_step(pipeline)["env"]["VERSION_BUMP_MERGE_AUTO"] == "false" +def test_phase2_dra_uses_wait_script_not_meta_in_if() -> None: + pipeline = _run_phase2() + dra = _step_by_key(pipeline, "fetch-dra-artifacts") + assert "if" not in dra + assert "plugins" not in dra + assert dra["command"] == ["python3", "dev-tools/wait_version_bump_dra.py"] -def test_dra_step_requires_bump_meta_and_not_dry_run() -> None: - pipeline = _run_pipeline_generator() - cond = _dra_step(pipeline)["if"] - assert 'build.meta_data("ml_cpp_version_bump_noop") != "true"' in cond - assert 'build.env("DRY_RUN") != "true"' in cond - assert 'build.meta_data("ml_cpp_version_bump_changed") == "true"' in cond +def test_phase2_slack_depends_on_schedule_key() -> None: + pipeline = _run_phase2() + slack = _step_by_key(pipeline, "queue-slack-notify") + assert slack["depends_on"] == "schedule-version-bump-follow-up" -def test_slack_and_bump_skip_when_validate_noop_meta() -> None: - pipeline = _run_pipeline_generator() - want = 'build.meta_data("ml_cpp_version_bump_noop") != "true"' - assert _slack_step(pipeline)["if"] == want - assert _bump_step(pipeline)["if"] == want - assert _bump_step(pipeline)["depends_on"] == "validate-version-bump" +def test_phase2_bump_depends_on_slack() -> None: + pipeline = _run_phase2() + bump = _step_by_key(pipeline, "bump-version") + assert bump["depends_on"] == "queue-slack-notify" def test_mutually_exclusive_merge_flags_script() -> None: """create_github_pull_request.sh rejects --merge and --merge-auto together.""" script = _REPO_ROOT / "dev-tools" / "create_github_pull_request.sh" proc = subprocess.run( - ["bash", str(script), "--repo", "r/r", "--base", "b", "--head", "h", "--title", "t", "--merge", "--merge-auto"], + [ + "bash", + str(script), + "--repo", + "r/r", + "--base", + "b", + "--head", + "h", + "--title", + "t", + "--merge", + "--merge-auto", + ], cwd=str(_REPO_ROOT), capture_output=True, text=True, diff --git a/dev-tools/version_bump_upload_phase2.sh b/dev-tools/version_bump_upload_phase2.sh new file mode 100755 index 000000000..82be7561c --- /dev/null +++ b/dev-tools/version_bump_upload_phase2.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. +# +# Second phase of the ml-cpp version-bump pipeline (after validate). Buildkite step +# `if` cannot read build meta-data, so we gate follow-up steps by reading +# ml_cpp_version_bump_noop here and uploading phase-2 YAML only when a bump is needed. + +set -euo pipefail + +if [[ -n "${BUILDKITE_BUILD_CHECKOUT_PATH:-}" ]]; then + cd "${BUILDKITE_BUILD_CHECKOUT_PATH}" +else + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -z "${ROOT}" ]]; then + echo "ERROR: set BUILDKITE_BUILD_CHECKOUT_PATH or run from a git checkout" >&2 + exit 1 + fi + cd "${ROOT}" +fi + +if [[ "${DRY_RUN:-}" == "true" ]]; then + echo "DRY_RUN=true — not scheduling version-bump follow-up steps." + exit 0 +fi + +if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "ERROR: buildkite-agent not found; cannot upload phase-2 pipeline." >&2 + exit 1 +fi + +noop=$(buildkite-agent meta-data get "ml_cpp_version_bump_noop" 2>/dev/null || echo "false") +if [[ "${noop}" == "true" ]]; then + echo "ml_cpp_version_bump_noop=true — branch already at NEW_VERSION; skipping follow-up steps." + exit 0 +fi + +exec python3 .buildkite/job-version-bump-phase2.json.py | buildkite-agent pipeline upload diff --git a/dev-tools/wait_version_bump_dra.py b/dev-tools/wait_version_bump_dra.py new file mode 100755 index 000000000..d611680ed --- /dev/null +++ b/dev-tools/wait_version_bump_dra.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0 and the following additional limitation. Functionality enabled by the +# files subject to the Elastic License 2.0 may only be used in production when +# invoked by an Elasticsearch process with a license key installed that permits +# use of machine learning features. You may not use this file except in +# compliance with the Elastic License 2.0 and the foregoing additional +# limitation. + +"""Poll DRA staging/snapshot JSON until versions match (replaces json-watcher plugin). + +Buildkite step conditionals cannot use build meta-data; this script reads +ml_cpp_version_bump_changed via ``buildkite-agent meta-data get`` and exits +immediately when no PR was opened. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request + +POLL_SECONDS = 30 +TIMEOUT_SECONDS = 240 * 60 + +STAGING_TMPL = "https://artifacts-staging.elastic.co/ml-cpp/latest/{branch}.json" +SNAPSHOT_TMPL = "https://storage.googleapis.com/elastic-artifacts-snapshot/ml-cpp/latest/{branch}.json" + + +def _meta_get(key: str) -> str | None: + if os.environ.get("BUILDKITE") != "true": + return None + try: + proc = subprocess.run( + ["buildkite-agent", "meta-data", "get", key], + capture_output=True, + text=True, + check=True, + timeout=60, + ) + v = proc.stdout.strip() + return v if v else None + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + return None + + +def _fetch_version(url: str) -> str | None: + try: + req = urllib.request.Request(url, headers={"User-Agent": "ml-cpp-version-bump-dra-wait"}) + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read().decode("utf-8")) + ver = data.get("version") + if ver is None: + return None + return str(ver).strip() + except (urllib.error.URLError, json.JSONDecodeError, UnicodeDecodeError, ValueError): + return None + + +def main() -> int: + if os.environ.get("DRY_RUN") == "true": + print("DRY_RUN=true — skipping DRA wait.") + return 0 + + if _meta_get("ml_cpp_version_bump_changed") != "true": + print( + "ml_cpp_version_bump_changed is not true — no PR opened; skipping DRA wait.", + file=sys.stderr, + ) + return 0 + + branch = os.environ.get("BRANCH", "").strip() + new_version = os.environ.get("NEW_VERSION", "").strip() + if not branch or not new_version: + print("ERROR: BRANCH and NEW_VERSION must be set.", file=sys.stderr) + return 1 + + staging_url = STAGING_TMPL.format(branch=branch) + snapshot_url = SNAPSHOT_TMPL.format(branch=branch) + want_staging = new_version + want_snapshot = f"{new_version}-SNAPSHOT" + + print(f"Waiting for DRA artifacts (timeout {TIMEOUT_SECONDS}s, poll {POLL_SECONDS}s)...") + print(f" staging: {want_staging!r} <= {staging_url}") + print(f" snapshot: {want_snapshot!r} <= {snapshot_url}") + + deadline = time.monotonic() + TIMEOUT_SECONDS + while time.monotonic() < deadline: + st = _fetch_version(staging_url) + sn = _fetch_version(snapshot_url) + if st == want_staging and sn == want_snapshot: + print("OK: staging and snapshot versions matched.") + return 0 + if st is not None or sn is not None: + print(f" staging={st!r} snapshot={sn!r} (still waiting)") + time.sleep(POLL_SECONDS) + + print("ERROR: timed out waiting for DRA artifact versions.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From cbebf1d452111f6daac5f3494afb90bd57b631ac Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 16:07:45 +1200 Subject: [PATCH 29/32] [ML] Notify Slack after version bump PR with approval link Co-authored-by: Cursor --- .buildkite/job-version-bump-phase2.json.py | 24 +++++----- .../send_slack_version_bump_notification.sh | 44 ++++++++++++++++--- dev-tools/bump_version.sh | 23 +++++++++- .../test_job_version_bump_pipeline.py | 21 +++++---- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/.buildkite/job-version-bump-phase2.json.py b/.buildkite/job-version-bump-phase2.json.py index da9a271fc..1216b600c 100755 --- a/.buildkite/job-version-bump-phase2.json.py +++ b/.buildkite/job-version-bump-phase2.json.py @@ -22,19 +22,10 @@ def main(): pipeline_steps = [ - { - "label": "Queue a :slack: notification for the pipeline", - "key": "queue-slack-notify", - "depends_on": "schedule-version-bump-follow-up", - "command": ".buildkite/pipelines/send_slack_version_bump_notification.sh | buildkite-agent pipeline upload", - "agents": { - "image": "python", - }, - }, { "label": "Bump version to ${NEW_VERSION}", "key": "bump-version", - "depends_on": "queue-slack-notify", + "depends_on": "schedule-version-bump-follow-up", "agents": { "image": WOLFI_IMAGE, "cpu": "250m", @@ -47,10 +38,21 @@ def main(): "dev-tools/bump_version.sh", ], }, + { + "label": "Notify :slack: — version bump PR needs approval", + "key": "queue-slack-notify", + "depends_on": "bump-version", + "command": [ + ".buildkite/pipelines/send_slack_version_bump_notification.sh", + ], + "agents": { + "image": "python", + }, + }, { "label": "Fetch DRA Artifacts", "key": "fetch-dra-artifacts", - "depends_on": "bump-version", + "depends_on": "queue-slack-notify", "agents": { "image": WOLFI_IMAGE, "cpu": "250m", diff --git a/.buildkite/pipelines/send_slack_version_bump_notification.sh b/.buildkite/pipelines/send_slack_version_bump_notification.sh index 3e99d0c97..434a2eda2 100755 --- a/.buildkite/pipelines/send_slack_version_bump_notification.sh +++ b/.buildkite/pipelines/send_slack_version_bump_notification.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one # or more contributor license agreements. Licensed under the Elastic License # 2.0 and the following additional limitation. Functionality enabled by the @@ -8,14 +8,42 @@ # compliance with the Elastic License 2.0 and the foregoing additional # limitation. # -# Slack notifications for the ml-cpp-version-bump pipeline only (not PR builds). +# Single Slack notification for the ml-cpp-version-bump pipeline: runs after the +# bump step opens the PR. Reads ml_cpp_version_bump_pr_url from Buildkite meta-data +# (set by dev-tools/bump_version.sh) and posts the PR link so reviewers can approve. # # Optional env: # ML_CPP_VERSION_BUMP_SLACK_CHANNEL — override channel (default #machine-learn-build) +set -euo pipefail + CHANNEL="${ML_CPP_VERSION_BUMP_SLACK_CHANNEL:-#machine-learn-build}" -cat </dev/null 2>&1; then + pr_url=$(buildkite-agent meta-data get "ml_cpp_version_bump_pr_url" 2>/dev/null || true) + changed=$(buildkite-agent meta-data get "ml_cpp_version_bump_changed" 2>/dev/null || echo "false") +fi + +if [[ -z "${pr_url}" && "${changed}" != "true" ]]; then + echo "No version-bump PR opened; skipping Slack notification." + exit 0 +fi + +if [[ -z "${pr_url}" && "${changed}" == "true" ]]; then + body_line="DRY RUN — no pull request URL (simulated bump)." +else + body_line="Pull request (approval required): ${pr_url}" +fi + +if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "WARNING: buildkite-agent not found; skipping Slack pipeline upload." >&2 + exit 0 +fi + +( + cat </dev/null 2>&1; then + echo "WARNING: BUILDKITE=true but buildkite-agent not in PATH; skipping meta-data ml_cpp_version_bump_pr_url" >&2 + return 0 + fi + buildkite-agent meta-data set "ml_cpp_version_bump_pr_url" "$url" +} + bump_version_via_pr() { local target_branch="$1" local target_version="$2" @@ -116,6 +130,7 @@ bump_version_via_pr() { # Default: no DRA wait unless we open a PR (or DRY_RUN simulates one). version_bump_set_buildkite_meta_changed false + version_bump_set_pr_url_meta "" topic_branch=$(topic_branch_name) @@ -144,6 +159,7 @@ bump_version_via_pr() { if [ "$current_version" = "$target_version" ]; then echo "Version on origin/${target_branch} is already ${target_version} — nothing to do" + version_bump_set_pr_url_meta "" return 0 fi @@ -158,6 +174,7 @@ bump_version_via_pr() { if git diff-index --quiet HEAD --; then echo "No changes to commit (file unchanged after sed)" + version_bump_set_pr_url_meta "" return 0 fi @@ -168,6 +185,7 @@ bump_version_via_pr() { if [ "$DRY_RUN" = "true" ]; then echo " [DRY RUN] Would push origin ${topic_branch} and open PR into ${target_branch}" version_bump_set_buildkite_meta_changed true + version_bump_set_pr_url_meta "" return 0 fi @@ -210,6 +228,7 @@ EOF pr_url=$("${pr_cmd[@]}") echo " Pull request: ${pr_url}" version_bump_set_buildkite_meta_changed true + version_bump_set_pr_url_meta "$pr_url" } echo "=== Patch version bump (PR workflow): ${BRANCH} → ${NEW_VERSION} ===" diff --git a/dev-tools/unittest/test_job_version_bump_pipeline.py b/dev-tools/unittest/test_job_version_bump_pipeline.py index 499f0a25b..c254092d3 100644 --- a/dev-tools/unittest/test_job_version_bump_pipeline.py +++ b/dev-tools/unittest/test_job_version_bump_pipeline.py @@ -98,16 +98,19 @@ def test_phase2_dra_uses_wait_script_not_meta_in_if() -> None: assert dra["command"] == ["python3", "dev-tools/wait_version_bump_dra.py"] -def test_phase2_slack_depends_on_schedule_key() -> None: +def test_phase2_order_bump_then_slack_then_dra() -> None: pipeline = _run_phase2() - slack = _step_by_key(pipeline, "queue-slack-notify") - assert slack["depends_on"] == "schedule-version-bump-follow-up" - - -def test_phase2_bump_depends_on_slack() -> None: - pipeline = _run_phase2() - bump = _step_by_key(pipeline, "bump-version") - assert bump["depends_on"] == "queue-slack-notify" + assert ( + _step_by_key(pipeline, "bump-version")["depends_on"] + == "schedule-version-bump-follow-up" + ) + assert _step_by_key(pipeline, "queue-slack-notify")["depends_on"] == "bump-version" + slack_cmd = _step_by_key(pipeline, "queue-slack-notify")["command"] + assert slack_cmd == [".buildkite/pipelines/send_slack_version_bump_notification.sh"] + assert ( + _step_by_key(pipeline, "fetch-dra-artifacts")["depends_on"] + == "queue-slack-notify" + ) def test_mutually_exclusive_merge_flags_script() -> None: From 59718244bd4e6e4c07cb566da185eb98cf10319a Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 16:27:37 +1200 Subject: [PATCH 30/32] [ML] Fix version bump: do not set empty Buildkite PR URL meta-data Co-authored-by: Cursor --- dev-tools/bump_version.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dev-tools/bump_version.sh b/dev-tools/bump_version.sh index 33bebdb9d..3f9d8ca98 100755 --- a/dev-tools/bump_version.sh +++ b/dev-tools/bump_version.sh @@ -37,7 +37,8 @@ # # Buildkite (BUILDKITE=true): sets meta-data: # ml_cpp_version_bump_changed — true|false so the DRA wait step can skip when no PR was opened -# ml_cpp_version_bump_pr_url — HTTPS URL of the opened PR (empty if none / dry-run) +# ml_cpp_version_bump_pr_url — HTTPS URL of the opened PR (only set when non-empty; +# Buildkite forbids empty meta-data values) # # Follows the same pattern as the Elasticsearch repo's automated # Lucene snapshot updates (.buildkite/scripts/lucene-snapshot/). @@ -110,9 +111,13 @@ version_bump_set_buildkite_meta_changed() { buildkite-agent meta-data set "ml_cpp_version_bump_changed" "$changed" } -# PR URL for the Slack step (after bump). Empty when no PR was created. +# PR URL for the Slack step (after bump). Omit calling when there is no URL — Buildkite +# rejects meta-data set with an empty value ("value cannot be empty…"). version_bump_set_pr_url_meta() { local url="${1:-}" + if [[ -z "${url}" ]]; then + return 0 + fi if [[ "${BUILDKITE:-}" != "true" ]]; then return 0 fi @@ -130,7 +135,6 @@ bump_version_via_pr() { # Default: no DRA wait unless we open a PR (or DRY_RUN simulates one). version_bump_set_buildkite_meta_changed false - version_bump_set_pr_url_meta "" topic_branch=$(topic_branch_name) @@ -159,7 +163,6 @@ bump_version_via_pr() { if [ "$current_version" = "$target_version" ]; then echo "Version on origin/${target_branch} is already ${target_version} — nothing to do" - version_bump_set_pr_url_meta "" return 0 fi @@ -174,7 +177,6 @@ bump_version_via_pr() { if git diff-index --quiet HEAD --; then echo "No changes to commit (file unchanged after sed)" - version_bump_set_pr_url_meta "" return 0 fi @@ -185,7 +187,6 @@ bump_version_via_pr() { if [ "$DRY_RUN" = "true" ]; then echo " [DRY RUN] Would push origin ${topic_branch} and open PR into ${target_branch}" version_bump_set_buildkite_meta_changed true - version_bump_set_pr_url_meta "" return 0 fi From 0e760ef336a9d467a949f02082af166b8686a31e Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 16:43:04 +1200 Subject: [PATCH 31/32] [ML] Fix version-bump Slack: use Wolfi agent image and harden notify script Co-authored-by: Cursor --- .buildkite/job-version-bump-phase2.json.py | 6 ++++- .../send_slack_version_bump_notification.sh | 27 ++++++++++++------- .../test_job_version_bump_pipeline.py | 8 ++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.buildkite/job-version-bump-phase2.json.py b/.buildkite/job-version-bump-phase2.json.py index 1216b600c..a05f2664a 100755 --- a/.buildkite/job-version-bump-phase2.json.py +++ b/.buildkite/job-version-bump-phase2.json.py @@ -46,7 +46,11 @@ def main(): ".buildkite/pipelines/send_slack_version_bump_notification.sh", ], "agents": { - "image": "python", + # Same image as bump-version: the minimal python image does not ship + # buildkite-agent, so meta-data get / pipeline upload silently skipped Slack. + "image": WOLFI_IMAGE, + "cpu": "250m", + "memory": "512Mi", }, }, { diff --git a/.buildkite/pipelines/send_slack_version_bump_notification.sh b/.buildkite/pipelines/send_slack_version_bump_notification.sh index 434a2eda2..06b0ccef9 100755 --- a/.buildkite/pipelines/send_slack_version_bump_notification.sh +++ b/.buildkite/pipelines/send_slack_version_bump_notification.sh @@ -19,15 +19,27 @@ set -euo pipefail CHANNEL="${ML_CPP_VERSION_BUMP_SLACK_CHANNEL:-#machine-learn-build}" +if [[ "${BUILDKITE:-}" != "true" ]]; then + echo "BUILDKITE is not true — skipping Slack notification (local run)." + exit 0 +fi + +if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "ERROR: buildkite-agent not in PATH; cannot read meta-data or upload Slack notify pipeline." >&2 + echo "Use the same agent image as bump-version (Wolfi), not a minimal python image." >&2 + exit 1 +fi + pr_url="" changed="false" -if [[ "${BUILDKITE:-}" == "true" ]] && command -v buildkite-agent >/dev/null 2>&1; then - pr_url=$(buildkite-agent meta-data get "ml_cpp_version_bump_pr_url" 2>/dev/null || true) - changed=$(buildkite-agent meta-data get "ml_cpp_version_bump_changed" 2>/dev/null || echo "false") -fi +pr_url=$(buildkite-agent meta-data get "ml_cpp_version_bump_pr_url" 2>/dev/null || true) +changed=$(buildkite-agent meta-data get "ml_cpp_version_bump_changed" 2>/dev/null || echo "false") +# Meta-data values must not contain stray whitespace (Breaks truthiness.) +pr_url=$(echo -n "${pr_url}" | tr -d '\r') +changed=$(echo -n "${changed}" | tr -d '\r') if [[ -z "${pr_url}" && "${changed}" != "true" ]]; then - echo "No version-bump PR opened; skipping Slack notification." + echo "No version-bump PR opened (pr_url empty, ml_cpp_version_bump_changed=${changed}); skipping Slack notification." exit 0 fi @@ -37,11 +49,6 @@ else body_line="Pull request (approval required): ${pr_url}" fi -if ! command -v buildkite-agent >/dev/null 2>&1; then - echo "WARNING: buildkite-agent not found; skipping Slack pipeline upload." >&2 - exit 0 -fi - ( cat < None: ) +def test_phase2_slack_step_uses_same_agent_image_as_bump() -> None: + """Slack step must run where buildkite-agent is available (see send_slack script).""" + pipeline = _run_phase2() + bump_img = _step_by_key(pipeline, "bump-version")["agents"]["image"] + slack_img = _step_by_key(pipeline, "queue-slack-notify")["agents"]["image"] + assert slack_img == bump_img + + def test_mutually_exclusive_merge_flags_script() -> None: """create_github_pull_request.sh rejects --merge and --merge-auto together.""" script = _REPO_ROOT / "dev-tools" / "create_github_pull_request.sh" From 9293f24c6125f26fb93664ee84ad5e7814f23fa6 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Wed, 6 May 2026 16:57:57 +1200 Subject: [PATCH 32/32] [ML] Version bump Slack: use step notify so message sends without waiting for build end Co-authored-by: Cursor --- .../send_slack_version_bump_notification.sh | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/.buildkite/pipelines/send_slack_version_bump_notification.sh b/.buildkite/pipelines/send_slack_version_bump_notification.sh index 06b0ccef9..9105d3d41 100755 --- a/.buildkite/pipelines/send_slack_version_bump_notification.sh +++ b/.buildkite/pipelines/send_slack_version_bump_notification.sh @@ -12,6 +12,10 @@ # bump step opens the PR. Reads ml_cpp_version_bump_pr_url from Buildkite meta-data # (set by dev-tools/bump_version.sh) and posts the PR link so reviewers can approve. # +# Slack notify must live on the step (see Buildkite docs): build-level notify fires only +# on build.finished — after every downstream step including long DRA waits — so the +# message would appear hours late or never if someone checks earlier. +# # Optional env: # ML_CPP_VERSION_BUMP_SLACK_CHANNEL — override channel (default #machine-learn-build) @@ -54,21 +58,20 @@ fi steps: - label: "Schedule :slack: notification (version bump)" command: "echo schedule :slack: notification" -notify: - - slack: - channels: - - "${CHANNEL}" - message: | - **Version bump PR — approval required** - ${body_line} - Branch: \${BUILDKITE_BRANCH} - NEW_VERSION: \${NEW_VERSION:-"(unset)"} - BRANCH (param): \${BRANCH:-"(unset)"} - VERSION_BUMP_MERGE_AUTO: \${VERSION_BUMP_MERGE_AUTO:-"(unset)"} - DRY_RUN: \${DRY_RUN:-"(unset)"} - Pipeline: \${BUILDKITE_BUILD_URL} - Build: \${BUILDKITE_BUILD_NUMBER} - Please review and approve this pull request so it can merge (subject to branch protection). - if: build.pull_request.id == null + notify: + - slack: + channels: + - "${CHANNEL}" + message: | + **Version bump PR — approval required** + ${body_line} + Branch: \${BUILDKITE_BRANCH} + NEW_VERSION: \${NEW_VERSION:-"(unset)"} + BRANCH (param): \${BRANCH:-"(unset)"} + VERSION_BUMP_MERGE_AUTO: \${VERSION_BUMP_MERGE_AUTO:-"(unset)"} + DRY_RUN: \${DRY_RUN:-"(unset)"} + Pipeline: \${BUILDKITE_BUILD_URL} + Build: \${BUILDKITE_BUILD_NUMBER} + Please review and approve this pull request so it can merge (subject to branch protection). EOF ) | buildkite-agent pipeline upload