This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new bd1f374f02da chore(ci): add regen-push bot script and update commenter 
message (#26093)
bd1f374f02da is described below

commit bd1f374f02dace94c50d988bb95740747fc6e634
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Sep 4 00:20:11 2026 +0200

    chore(ci): add regen-push bot script and update commenter message (#26093)
    
    * chore(ci): add regen-push bot script and update commenter message
    
    Add etc/scripts/regen-push.sh — a standalone script that automatically
    pushes "Regen" commits to PR branches when CI detects uncommitted
    generated file changes. It downloads the regen-patch artifact produced
    by the CI build and pushes the fix directly.
    
    Works for both same-repo and fork PRs (requires maintainer access and
    "Allow edits from maintainers" enabled). Uses ETag-based polling on
    the Events API for efficient cron usage.
    
    Update pr-test-commenter.yml to inform contributors that a bot will
    auto-push the regen fix when maintainer edits are allowed, with manual
    instructions as fallback.
    
    * Regen
    
    * Restore script documentation header
    
    Add a blank line between the ASF license block and the script documentation
    to prevent the regen formatter from treating them as a single license block
    and stripping the documentation.
    
    ---------
    
    Co-authored-by: github-actions[bot] 
<github-actions[bot]@users.noreply.github.com>
---
 .github/workflows/pr-test-commenter.yml |   8 +-
 etc/scripts/regen-push.sh               | 335 ++++++++++++++++++++++++++++++++
 2 files changed, 340 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/pr-test-commenter.yml 
b/.github/workflows/pr-test-commenter.yml
index 90eb416c39cc..20f1e843d463 100644
--- a/.github/workflows/pr-test-commenter.yml
+++ b/.github/workflows/pr-test-commenter.yml
@@ -187,10 +187,12 @@ jobs:
                 '### :warning: Generated files are out of date',
                 '',
                 `The [CI build](${runUrl}) regenerated files that are not 
committed on this PR.`,
-                'The missing changes are available as the `regen-patch` 
artifact of that run.',
                 '',
-                'Anyone with push access to this branch (the PR author, or a 
committer when',
-                '*Allow edits from maintainers* is enabled) can apply and push 
them:',
+                'If *Allow edits from maintainers* is enabled, a maintainer 
bot will automatically',
+                'push a "Regen" commit to this branch shortly.',
+                '',
+                'Otherwise, you can apply the patch manually — the missing 
changes are available',
+                'as the `regen-patch` artifact of that run:',
                 '',
                 '```bash',
                 `gh pr checkout ${prNumber} --repo ${repoSlug}`,
diff --git a/etc/scripts/regen-push.sh b/etc/scripts/regen-push.sh
new file mode 100755
index 000000000000..a8266f942076
--- /dev/null
+++ b/etc/scripts/regen-push.sh
@@ -0,0 +1,335 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+#
+# regen-push.sh — Automatically push regen patches to PRs that need them.
+#
+# When the CI build regenerates files and finds uncommitted changes, it uploads
+# a "regen-patch" artifact. This script discovers those artifacts, downloads 
the
+# patch, and pushes a "Regen" commit to the PR branch.
+#
+# Works for both same-repo and fork PRs (requires maintainer access and
+# "Allow edits from maintainers" enabled on the PR).
+#
+# Uses ETag-based conditional requests on the Artifacts API to avoid
+# unnecessary work when no new regen artifacts have appeared.
+#
+# Usage:
+#   ./regen-push.sh [--repo OWNER/REPO] [--state PATH] [--dry-run]
+#
+# Environment:
+#   GH_TOKEN — GitHub token with repo scope (or gh CLI already authenticated)
+#
+# Exit codes:
+#   0 — success (patches pushed, or nothing to do)
+#   1 — error
+#
+# Designed to run as a cron job (e.g. every 5 minutes). ETag polling makes
+# idle ticks free (no API quota consumed on 304).
+
+set -euo pipefail
+
+REPO="apache/camel"
+STATE_FILE=""
+DRY_RUN=false
+VERBOSE=false
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --repo)      REPO="$2"; shift 2 ;;
+    --state)     STATE_FILE="$2"; shift 2 ;;
+    --dry-run)   DRY_RUN=true; shift ;;
+    --verbose)   VERBOSE=true; shift ;;
+    -h|--help)
+      echo "Usage: $0 [--repo OWNER/REPO] [--state PATH] [--dry-run] 
[--verbose]"
+      exit 0
+      ;;
+    *)           echo "Unknown option: $1" >&2; exit 1 ;;
+  esac
+done
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# State/ETag files default to XDG_STATE_HOME (~/.local/state) to keep the
+# source tree clean. Override with --state if needed.
+_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/regen-push"
+mkdir -p "$_STATE_DIR"
+STATE_FILE="${STATE_FILE:-$_STATE_DIR/$(echo "$REPO" | tr '/' '-').json}"
+
+log() { echo "$(date -u +%H:%M:%S) $*" >&2; }
+debug() { $VERBOSE && log "[debug] $*" || true; }
+
+# Ensure gh CLI is in PATH
+export PATH="/opt/data/toolchain/bin:$PATH"
+
+if ! command -v gh &>/dev/null; then
+  log "ERROR: gh CLI not found"
+  exit 1
+fi
+
+# Ensure we have a token
+if [[ -z "${GH_TOKEN:-}" ]]; then
+  if [[ -f /opt/data/.secrets/gh-token ]]; then
+    GH_TOKEN=$(cat /opt/data/.secrets/gh-token)
+  else
+    GH_TOKEN=$(gh auth token 2>/dev/null || true)
+  fi
+  if [[ -z "$GH_TOKEN" ]]; then
+    log "ERROR: No GitHub token available"
+    exit 1
+  fi
+  export GH_TOKEN
+fi
+
+# Initialize state file if missing
+if [[ ! -f "$STATE_FILE" ]]; then
+  echo '{"pushed": {}}' > "$STATE_FILE"
+fi
+
+# ── ETag-based activity check ──────────────────────────────────────────
+# Uses the Artifacts API directly with conditional requests 
(If-Modified-Since).
+# The artifacts endpoint returns new results when CI completes with a 
regen-patch.
+# This is a single API call per tick — when nothing changed, GitHub returns 304
+# (no quota consumed).
+
+ETAG_FILE="${STATE_FILE%.json}.etag"
+CACHED_ETAG=""
+[[ -f "$ETAG_FILE" ]] && CACHED_ETAG=$(cat "$ETAG_FILE")
+
+HEADER_TMP=$(mktemp)
+BODY_TMP=$(mktemp)
+trap "rm -f $HEADER_TMP $BODY_TMP" EXIT
+
+ETAG_HEADER=()
+[[ -n "$CACHED_ETAG" ]] && ETAG_HEADER=(-H "If-None-Match: $CACHED_ETAG")
+
+HTTP_CODE=$(curl -s -o "$BODY_TMP" -D "$HEADER_TMP" -w "%{http_code}" \
+  -H "Authorization: token $GH_TOKEN" \
+  -H "Accept: application/vnd.github+json" \
+  "${ETAG_HEADER[@]}" \
+  
"https://api.github.com/repos/${REPO}/actions/artifacts?name=regen-patch&per_page=5";
 2>/dev/null) || true
+
+NEW_ETAG=$(grep -i '^etag:' "$HEADER_TMP" 2>/dev/null \
+  | awk '{print $2}' | tr -d '\r\n' || true)
+[[ -n "$NEW_ETAG" ]] && echo -n "$NEW_ETAG" > "$ETAG_FILE"
+
+if [[ "$HTTP_CODE" == "304" ]]; then
+  debug "No new artifacts (304) — nothing to do"
+  exit 0
+fi
+
+debug "Artifact list changed (HTTP $HTTP_CODE) — checking for regen-patch 
artifacts"
+
+# ── Find regen-patch artifacts ─────────────────────────────────────────
+# The Artifacts API lets us search by name directly — one call.
+
+ARTIFACTS_JSON=$(gh api 
"repos/$REPO/actions/artifacts?name=regen-patch&per_page=30" \
+  --jq '[.artifacts[] | select(.expired == false) | {
+    artifact_id: .id,
+    run_id: .workflow_run.id,
+    head_sha: .workflow_run.head_sha,
+    head_branch: .workflow_run.head_branch,
+    created_at: .created_at
+  }]' 2>/dev/null) || {
+  log "ERROR: Failed to list artifacts"
+  exit 1
+}
+
+ARTIFACT_COUNT=$(echo "$ARTIFACTS_JSON" | python3 -c "import sys,json; 
print(len(json.load(sys.stdin)))")
+debug "Found $ARTIFACT_COUNT non-expired regen-patch artifacts"
+
+if [[ "$ARTIFACT_COUNT" -eq 0 ]]; then
+  exit 0
+fi
+
+# ── Process each artifact ──────────────────────────────────────────────
+# Write artifacts to a temp file and read with a redirect (not a pipe)
+# so the loop runs in the current shell and can update PUSHED_COUNT.
+
+ARTIFACTS_LIST=$(mktemp)
+echo "$ARTIFACTS_JSON" | python3 -c "
+import sys, json
+for a in json.load(sys.stdin):
+    
print(f\"{a['artifact_id']}|{a['run_id']}|{a['head_sha']}|{a['head_branch']}|{a['created_at']}\")
+" > "$ARTIFACTS_LIST"
+
+PUSHED_COUNT=0
+ORIG_DIR=$(pwd)
+
+while IFS='|' read -r ARTIFACT_ID RUN_ID HEAD_SHA HEAD_BRANCH CREATED_AT; do
+
+  debug "Checking artifact $ARTIFACT_ID (run $RUN_ID, branch $HEAD_BRANCH)"
+
+  # Skip if already pushed for this run
+  ALREADY_PUSHED=$(python3 -c "
+import json
+state = json.load(open('$STATE_FILE'))
+print('yes' if str($RUN_ID) in state.get('pushed', {}) else 'no')
+" 2>/dev/null)
+
+  if [[ "$ALREADY_PUSHED" == "yes" ]]; then
+    debug "  Already pushed for run $RUN_ID — skipping"
+    continue
+  fi
+
+  # Find the open PR for this branch
+  PR_JSON=$(gh pr list --repo "$REPO" --state open --head "$HEAD_BRANCH" \
+    --json 
number,headRepositoryOwner,headRepository,maintainerCanModify,headRefName \
+    --jq '.[0] // empty' 2>/dev/null) || true
+
+  if [[ -z "$PR_JSON" ]]; then
+    debug "  No open PR found for branch $HEAD_BRANCH — skipping"
+    continue
+  fi
+
+  PR_NUMBER=$(echo "$PR_JSON" | python3 -c "import sys,json; 
print(json.load(sys.stdin)['number'])")
+  CAN_MODIFY=$(echo "$PR_JSON" | python3 -c "import sys,json; 
print(json.load(sys.stdin).get('maintainerCanModify', False))")
+  FORK_OWNER=$(echo "$PR_JSON" | python3 -c "import sys,json; 
print(json.load(sys.stdin)['headRepositoryOwner']['login'])")
+
+  debug "  PR #$PR_NUMBER (fork: $FORK_OWNER, maintainerCanModify: 
$CAN_MODIFY)"
+
+  if [[ "$CAN_MODIFY" != "True" ]]; then
+    log "  PR #$PR_NUMBER: maintainerCanModify=false — skipping"
+    continue
+  fi
+
+  # Verify this artifact is from the latest run for this PR's head SHA.
+  # If the PR was updated after the regen-patch run, the artifact is stale.
+  PR_HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq 
'.headRefOid' 2>/dev/null)
+  if [[ "$PR_HEAD_SHA" != "$HEAD_SHA" ]]; then
+    debug "  PR #$PR_NUMBER head SHA ($PR_HEAD_SHA) != artifact SHA 
($HEAD_SHA) — stale, skipping"
+    continue
+  fi
+
+  log "PR #$PR_NUMBER needs regen (run $RUN_ID)"
+
+  if $DRY_RUN; then
+    log "  [dry-run] Would download and push regen patch"
+    continue
+  fi
+
+  # Download the patch
+  WORK_DIR=$(mktemp -d)
+  if ! gh run download "$RUN_ID" --repo "$REPO" -n regen-patch -D "$WORK_DIR" 
2>/dev/null; then
+    log "  ERROR: Failed to download regen-patch artifact"
+    rm -rf "$WORK_DIR"
+    continue
+  fi
+
+  PATCH_FILE="$WORK_DIR/regen.patch"
+  if [[ ! -s "$PATCH_FILE" ]]; then
+    log "  ERROR: Patch file is empty or missing"
+    rm -rf "$WORK_DIR"
+    continue
+  fi
+
+  # Clone the PR branch (shallow, single-branch) into a temp directory.
+  # For fork PRs, clone the fork repo directly so we can push to it.
+  CLONE_DIR=$(mktemp -d)
+  REPO_NAME=$(echo "$REPO" | cut -d/ -f2)
+  OWNER=$(echo "$REPO" | cut -d/ -f1)
+  if [[ "$FORK_OWNER" == "$OWNER" ]]; then
+    CLONE_REPO="$REPO"
+  else
+    CLONE_REPO="$FORK_OWNER/$REPO_NAME"
+  fi
+
+  debug "  Cloning $CLONE_REPO (branch $HEAD_BRANCH) into $CLONE_DIR"
+
+  if ! git clone --depth=1 --branch "$HEAD_BRANCH" \
+    "https://x-access-token:${GH_TOKEN}@github.com/${CLONE_REPO}.git"; \
+    "$CLONE_DIR" 2>/dev/null; then
+    log "  ERROR: Failed to clone $CLONE_REPO branch $HEAD_BRANCH"
+    rm -rf "$WORK_DIR" "$CLONE_DIR"
+    continue
+  fi
+
+  cd "$CLONE_DIR"
+  git config user.name "github-actions[bot]"
+  git config user.email "github-actions[bot]@users.noreply.github.com"
+
+  # Apply the patch.
+  # The patch was generated against the merge with the base branch, so it
+  # may not apply cleanly. Try with --3way as a fallback.
+  if git apply --index "$PATCH_FILE" 2>/dev/null; then
+    debug "  Patch applied cleanly"
+  elif git apply --index --3way "$PATCH_FILE" 2>/dev/null; then
+    log "  Patch applied with 3-way merge"
+  else
+    log "  ERROR: Patch failed to apply for PR #$PR_NUMBER — skipping"
+    cd "$ORIG_DIR"
+    rm -rf "$WORK_DIR" "$CLONE_DIR"
+    continue
+  fi
+
+  # Commit and push
+  git commit -m "Regen" >/dev/null 2>&1 || {
+    log "  Nothing to commit after applying patch — skipping"
+    cd "$ORIG_DIR"
+    rm -rf "$WORK_DIR" "$CLONE_DIR"
+    continue
+  }
+
+  if git push 2>/dev/null; then
+    log "✅ PR #$PR_NUMBER — pushed regen commit"
+    echo "✅ https://github.com/$REPO/pull/$PR_NUMBER — pushed regen commit"
+    PUSHED_COUNT=$((PUSHED_COUNT + 1))
+
+    # Update the <!-- regen-patch --> comment on the PR to mark it resolved
+    REGEN_COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" 
--paginate \
+      --jq '[.[] | select(.body | contains("<!-- regen-patch -->"))] | last | 
.id' 2>/dev/null || true)
+    if [[ -n "$REGEN_COMMENT_ID" && "$REGEN_COMMENT_ID" != "null" ]]; then
+      RESOLVED_BODY="<!-- regen-patch -->
+### :white_check_mark: Generated files have been updated
+
+A regen commit was automatically pushed to this branch. CI will re-run 
shortly."
+      gh api "repos/$REPO/issues/comments/$REGEN_COMMENT_ID" \
+        -X PATCH -f body="$RESOLVED_BODY" >/dev/null 2>&1 \
+        && debug "  Updated regen-patch comment to resolved" \
+        || log "  WARNING: Failed to update regen-patch comment"
+    fi
+
+    # Record in state file
+    python3 -c "
+import json
+state = json.load(open('$STATE_FILE'))
+state.setdefault('pushed', {})[str($RUN_ID)] = {
+    'pr': $PR_NUMBER,
+    'branch': '$HEAD_BRANCH',
+    'sha': '$HEAD_SHA',
+    'pushed_at': '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
+}
+# Keep only the last 100 entries to avoid unbounded growth
+pushed = state['pushed']
+if len(pushed) > 100:
+    oldest = sorted(pushed, key=lambda k: pushed[k].get('pushed_at', 
''))[:len(pushed)-100]
+    for k in oldest:
+        del pushed[k]
+with open('$STATE_FILE', 'w') as f:
+    json.dump(state, f, indent=2)
+" 2>/dev/null
+  else
+    log "  ERROR: Failed to push to $CLONE_REPO branch $HEAD_BRANCH"
+  fi
+
+  # Cleanup
+  cd "$ORIG_DIR"
+  rm -rf "$WORK_DIR" "$CLONE_DIR"
+
+done < "$ARTIFACTS_LIST"
+
+rm -f "$ARTIFACTS_LIST"

Reply via email to