From fb7e38fd199515f27fa5e520a450ab6edd115a47 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Tue, 7 Apr 2026 22:12:49 +0000 Subject: [PATCH 1/9] feat: implement automated release workflow with image promotion polling --- .github/workflows/release.yml | 135 ++++++++++++++++++++++++++++++++++ dev/tools/tag-promote-images | 11 +-- 2 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..5484c3aea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release Automation + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + pull-requests: write + id-token: write # Required for Google Auth + +jobs: + promote-and-wait: + name: Promote Images and Wait for PR + runs-on: ubuntu-latest + timeout-minutes: 360 # 6-hour limit for long k8s.io merge cycles + outputs: + pr_url: ${{ steps.extract-pr.outputs.pr_url }} + + steps: + - name: Checkout agent-sandbox + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Clone k8s.io + env: + GH_TOKEN: ${{ secrets.K8S_IO_PAT }} + run: | + cd .. + git clone https://github.com/kubernetes/k8s.io.git + cd k8s.io + git remote rename origin upstream + gh repo fork --clone=false || true # Ignore if already exists or fails to add remote if already there + # Ensure origin remote points to the fork + GH_USER=$(gh api user --jq .login) + git remote add origin https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git || git remote set-url origin https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: '${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}' + service_account: '${{ secrets.GCP_SERVICE_ACCOUNT }}' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Setup Git Identity + run: | + # Use the standard GitHub Actions Bot identity + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Run Promotion Script + id: extract-pr + env: + # Use a PAT with permissions to fork/PR kubernetes/k8s.io + GH_TOKEN: ${{ secrets.K8S_IO_PAT }} + run: | + # Capture output to find the PR URL directly + make release-promote TAG="${GITHUB_REF_NAME}" K8S_IO_DIR=../k8s.io | tee promote_output.log + + # Extract the URL from the log + PR_URL=$(grep -o 'https://github.com/kubernetes/k8s.io/pull/[0-9]*' promote_output.log | head -n 1) + + if [ -z "$PR_URL" ]; then + echo "❌ No PR URL found in logs. Check make release-promote output." + exit 1 + fi + + echo "Found PR: $PR_URL" + echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT + + - name: Poll PR Status + env: + GH_TOKEN: ${{ secrets.K8S_IO_PAT }} + run: | + PR_URL="${{ steps.extract-pr.outputs.pr_url }}" + echo "Waiting for $PR_URL to be merged..." + + # 60 attempts, every 5 minutes = 5 hours total + for i in {1..60}; do + # Explicitly check the kubernetes/k8s.io repo context + STATE=$(gh pr view "$PR_URL" --repo kubernetes/k8s.io --json state --jq '.state') + + echo "Attempt $i: PR is currently $STATE" + + if [ "$STATE" == "MERGED" ]; then + echo "✅ PR merged. Proceeding." + exit 0 + elif [ "$STATE" == "CLOSED" ]; then + echo "❌ PR was closed without merging." + exit 1 + fi + + sleep 300 + done + + echo "❌ Timed out waiting for merge." + exit 1 + + publish-draft: + name: Publish Draft Release + needs: promote-and-wait + runs-on: ubuntu-latest + environment: + name: agent-sandbox-release + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Generate Draft Release and Manifests + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + # This finally publishes the draft for your review + make release-publish TAG="${GITHUB_REF_NAME}" \ No newline at end of file diff --git a/dev/tools/tag-promote-images b/dev/tools/tag-promote-images index 23a469cef..ab9e10caf 100755 --- a/dev/tools/tag-promote-images +++ b/dev/tools/tag-promote-images @@ -95,17 +95,10 @@ def main(): # --- Pre-flight Check --- check_local_repo_state(REMOTE_UPSTREAM) - check_tag_exists(tag, remote=REMOTE_UPSTREAM) - # --- Step 1: Create and Push Tag --- - print(f"--- Step 1: Handling Git Tag {tag} and Publish to PyPI---") - create_and_push_tag(tag, remote=REMOTE_UPSTREAM) - - print("✅ Done! The 'pypi-publish' GitHub Action should now be running.") - - # --- Step 2: Wait for Staging Images --- + # --- Step 1: Wait for Staging Images --- # CI job: https://prow.k8s.io/job-history/gs/kubernetes-ci-logs/logs/post-agent-sandbox-push-images - print(f"--- Step 2: Waiting for Staging Images ---") + print(f"--- Step 1: Waiting for Staging Images ---") print("⏳ Polling registry (timeout: 45 mins)...") collected_digests = {} From 7528983e8847d9febc95563e4f50d02f8a97ba50 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Tue, 7 Apr 2026 22:46:55 +0000 Subject: [PATCH 2/9] nit --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5484c3aea..077e7ccdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: gh repo fork --clone=false || true # Ignore if already exists or fails to add remote if already there # Ensure origin remote points to the fork GH_USER=$(gh api user --jq .login) - git remote add origin https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git || git remote set-url origin https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git + git remote add origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" || git remote set-url origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 @@ -75,7 +75,7 @@ jobs: fi echo "Found PR: $PR_URL" - echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - name: Poll PR Status env: From a722f6e5ef57e5dbfd9b3661574b71c09da02ced Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Mon, 13 Apr 2026 17:30:29 +0000 Subject: [PATCH 3/9] Address comments --- .github/workflows/release.yml | 36 +++++++++++++++++++++-------------- dev/tools/tag-promote-images | 9 +++++++++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 077e7ccdf..c283c6e29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ permissions: id-token: write # Required for Google Auth jobs: - promote-and-wait: - name: Promote Images and Wait for PR + promote: + name: Promote Images runs-on: ubuntu-latest - timeout-minutes: 360 # 6-hour limit for long k8s.io merge cycles + timeout-minutes: 30 outputs: pr_url: ${{ steps.extract-pr.outputs.pr_url }} @@ -31,6 +31,7 @@ jobs: - name: Clone k8s.io env: + # Use a Fine-Grained PAT scoped to kubernetes/k8s.io with contents:write and pull_requests:write GH_TOKEN: ${{ secrets.K8S_IO_PAT }} run: | cd .. @@ -60,28 +61,35 @@ jobs: - name: Run Promotion Script id: extract-pr env: - # Use a PAT with permissions to fork/PR kubernetes/k8s.io + # Use a Fine-Grained PAT scoped to kubernetes/k8s.io with contents:write and pull_requests:write GH_TOKEN: ${{ secrets.K8S_IO_PAT }} run: | - # Capture output to find the PR URL directly - make release-promote TAG="${GITHUB_REF_NAME}" K8S_IO_DIR=../k8s.io | tee promote_output.log - - # Extract the URL from the log - PR_URL=$(grep -o 'https://github.com/kubernetes/k8s.io/pull/[0-9]*' promote_output.log | head -n 1) + # Run promotion script + make release-promote TAG="${GITHUB_REF_NAME}" K8S_IO_DIR=../k8s.io - if [ -z "$PR_URL" ]; then - echo "❌ No PR URL found in logs. Check make release-promote output." + # Read the PR URL from the file generated by the script + if [ ! -f promote_pr.url ]; then + echo "❌ promote_pr.url file not found. Check make release-promote output." exit 1 fi + PR_URL=$(cat promote_pr.url) echo "Found PR: $PR_URL" echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - - name: Poll PR Status + # Restart release from this step if the job times out + poll-merge: + name: Poll PR Status + needs: promote + runs-on: ubuntu-latest + timeout-minutes: 360 # 6-hour limit for long k8s.io merge cycles + steps: + - name: Wait for PR Merge env: + # Use a Fine-Grained PAT scoped to kubernetes/k8s.io with contents:write and pull_requests:write GH_TOKEN: ${{ secrets.K8S_IO_PAT }} + PR_URL: ${{ needs.promote.outputs.pr_url }} run: | - PR_URL="${{ steps.extract-pr.outputs.pr_url }}" echo "Waiting for $PR_URL to be merged..." # 60 attempts, every 5 minutes = 5 hours total @@ -107,7 +115,7 @@ jobs: publish-draft: name: Publish Draft Release - needs: promote-and-wait + needs: poll-merge runs-on: ubuntu-latest environment: name: agent-sandbox-release diff --git a/dev/tools/tag-promote-images b/dev/tools/tag-promote-images index ab9e10caf..a2a6b5b17 100755 --- a/dev/tools/tag-promote-images +++ b/dev/tools/tag-promote-images @@ -195,6 +195,15 @@ def main(): ], cwd=k8s_io_dir, capture_output=True) print(f"✅ Promotion PR created: {pr_url}") + + # Write PR URL to a file for use in CI workflows + try: + with open("promote_pr.url", "w") as f: + f.write(pr_url.strip()) + print("💾 Saved PR URL to promote_pr.url") + except Exception as e: + print(f"⚠️ Failed to save PR URL to file: {e}") + print("👉 Action Required: Merge the PR and wait for the images to be promoted to registry.k8s.io") if __name__ == '__main__': From 7d732be15e78fdb5b900a7cebed9e41cc540f0f3 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 16 Apr 2026 14:31:44 +0000 Subject: [PATCH 4/9] Scheduled Release --- .github/workflows/release.yml | 53 ++++++++--- dev/tools/auto-tag | 69 ++++++++++++++ dev/tools/generate-release-notes | 150 +++++++++++++++++++++++++++++++ dev/tools/release | 32 ++++++- dev/tools/tag-promote-images | 36 ++++++-- 5 files changed, 316 insertions(+), 24 deletions(-) create mode 100755 dev/tools/auto-tag create mode 100755 dev/tools/generate-release-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c283c6e29..982027ba6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,19 @@ -name: Release Automation +name: Scheduled Release Automation on: - push: - tags: - - 'v*' + # Version tag is incremented by patch update + schedule: + - cron: '0 6 * * 5' # Every Friday at 06:00 UTC + # For Major/Minor version updates, enter tag manually + workflow_dispatch: + inputs: + tag: + description: 'Tag for release (e.g., v0.2.0). Leave blank for auto-patch.' + required: false + type: string permissions: - contents: write + contents: write # Allows to create and push tags pull-requests: write id-token: write # Required for Google Auth @@ -17,12 +24,17 @@ jobs: timeout-minutes: 30 outputs: pr_url: ${{ steps.extract-pr.outputs.pr_url }} + run_release: ${{ steps.set-tag.outputs.run_release || steps.auto-tag.outputs.run_release }} + new_tag: ${{ steps.set-tag.outputs.new_tag || steps.auto-tag.outputs.new_tag }} steps: - name: Checkout agent-sandbox uses: actions/checkout@v4 with: fetch-depth: 0 + # To ensure pushing the tag during the workflow has the permission to trigger the PyPI publish workflow. + # Github secret has repo access. + token: ${{ secrets.GH_AUTOMATION_PAT }} - name: Set up Go uses: actions/setup-go@v5 @@ -58,14 +70,29 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" + - name: Set Tag from Input + if: github.event.inputs.tag != '' + id: set-tag + run: | + echo "run_release=true" >> $GITHUB_OUTPUT + echo "new_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + + - name: Auto Tag if New Commits + if: github.event.inputs.tag == '' + id: auto-tag + run: | + ./dev/tools/auto-tag + - name: Run Promotion Script + if: steps.set-tag.outputs.run_release == 'true' || steps.auto-tag.outputs.run_release == 'true' id: extract-pr env: # Use a Fine-Grained PAT scoped to kubernetes/k8s.io with contents:write and pull_requests:write GH_TOKEN: ${{ secrets.K8S_IO_PAT }} run: | # Run promotion script - make release-promote TAG="${GITHUB_REF_NAME}" K8S_IO_DIR=../k8s.io + TAG="${{ steps.set-tag.outputs.new_tag || steps.auto-tag.outputs.new_tag }}" + make release-promote TAG="$TAG" K8S_IO_DIR=../k8s.io # Read the PR URL from the file generated by the script if [ ! -f promote_pr.url ]; then @@ -81,8 +108,9 @@ jobs: poll-merge: name: Poll PR Status needs: promote + if: needs.promote.outputs.run_release == 'true' runs-on: ubuntu-latest - timeout-minutes: 360 # 6-hour limit for long k8s.io merge cycles + timeout-minutes: 720 # 12-hour limit for long k8s.io merge cycles steps: - name: Wait for PR Merge env: @@ -92,8 +120,8 @@ jobs: run: | echo "Waiting for $PR_URL to be merged..." - # 60 attempts, every 5 minutes = 5 hours total - for i in {1..60}; do + # 144 attempts, every 5 minutes = 12 hours total + for i in {1..144}; do # Explicitly check the kubernetes/k8s.io repo context STATE=$(gh pr view "$PR_URL" --repo kubernetes/k8s.io --json state --jq '.state') @@ -115,10 +143,9 @@ jobs: publish-draft: name: Publish Draft Release - needs: poll-merge + needs: [promote, poll-merge] + if: needs.promote.outputs.run_release == 'true' runs-on: ubuntu-latest - environment: - name: agent-sandbox-release steps: - name: Checkout @@ -140,4 +167,4 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: | # This finally publishes the draft for your review - make release-publish TAG="${GITHUB_REF_NAME}" \ No newline at end of file + make release-publish TAG="${{ needs.promote.outputs.new_tag }}" \ No newline at end of file diff --git a/dev/tools/auto-tag b/dev/tools/auto-tag new file mode 100755 index 000000000..d1bf5db71 --- /dev/null +++ b/dev/tools/auto-tag @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Kubernetes Authors. +# +# Licensed 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. + +import os +import sys +import re +import subprocess + +from shared.git_ops import run_command, validate_tag, check_tag_exists, create_and_push_tag + +def increment_patch(version_str): + """Increments the patch version of a SemVer string (e.g., v0.1.0 -> v0.1.1).""" + match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', version_str) + if not match: + print(f"❌ Cannot parse version {version_str} for auto-increment.") + sys.exit(1) + major, minor, patch = map(int, match.groups()) + return f"v{major}.{minor}.{patch + 1}" + +def main(): + + print("🔍 Checking for new commits since last release...") + + # Get latest tag + try: + latest_tag = run_command(["git", "describe", "--tags", "--abbrev=0", "--match=v*"], capture_output=True) + except Exception as e: + print(f"⚠️ Error running git describe: {e}") + print(" Assuming no tags exist or history is missing. Defaulting to v0.1.0 base.") + latest_tag = "v0.1.0" + + print(f"Found latest tag: {latest_tag}") + + # Check for commits since latest tag + commits = run_command(["git", "log", f"{latest_tag}..HEAD", "--oneline"], capture_output=True) + + if not commits: + print("ℹ️ No new commits since last release. Skipping release.") + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write("run_release=false\n") + sys.exit(0) + + print(f"Found new commits since {latest_tag}:\n{commits}") + + new_tag = increment_patch(latest_tag) + print(f"🚀 Proposing new tag: {new_tag}") + + # Set output for GitHub Actions + if "GITHUB_OUTPUT" in os.environ: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write("run_release=true\n") + f.write(f"new_tag={new_tag}\n") + print(f"SUCCESS: Auto-patched to tag {new_tag}") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/dev/tools/generate-release-notes b/dev/tools/generate-release-notes new file mode 100755 index 000000000..c86f66f08 --- /dev/null +++ b/dev/tools/generate-release-notes @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Kubernetes Authors. +# +# Licensed 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. + +import os +import sys +import json +import urllib.request +import subprocess +import argparse + +def get_previous_tag(current_tag): + try: + tags_out = subprocess.run( + ["git", "tag", "--list", "v*", "--sort=-v:refname"], + check=True, text=True, stdout=subprocess.PIPE + ).stdout.splitlines() + + if current_tag in tags_out: + idx = tags_out.index(current_tag) + if idx + 1 < len(tags_out): + return tags_out[idx + 1] + if len(tags_out) > 1: + return tags_out[1] + return None + except subprocess.CalledProcessError: + return None + +def get_commit_titles(prev_tag, current_tag): + range_str = f"{prev_tag}..{current_tag}" if prev_tag else current_tag + try: + titles = subprocess.run( + ["git", "log", range_str, "--pretty=format:%s"], + check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + return titles + except subprocess.CalledProcessError: + return "" + +def generate_highlights(titles): + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + print("⚠️ GEMINI_API_KEY not set. Skipping Gemini highlights.") + return None + + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}" + + prompt = f"You are a release assistant. Please summarize the following commit titles into a professional release note. Include an introductory paragraph highlighting the main achievements of this release, followed by a section called '### Key Highlights' with a bulleted list grouped logically. Keep it concise and focused on user-visible changes.\n\n{titles}" + + data = { + "contents": [{ + "parts": [{"text": prompt}] + }] + } + + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST" + ) + + try: + with urllib.request.urlopen(req) as response: + resp_data = json.loads(response.read().decode("utf-8")) + candidates = resp_data.get("candidates", []) + if candidates: + parts = candidates[0].get("content", {}).get("parts", []) + if parts: + return parts[0].get("text") + return None + except Exception as e: + print(f"⚠️ Error calling Gemini API: {e}") + return None + +def main(): + parser = argparse.ArgumentParser(description='Generate and preview release notes.') + parser.add_argument('--tag', required=True, help='The target git tag for the release.') + parser.add_argument('--output', default='release_notes_preview.md', help='File to save notes to.') + args = parser.parse_args() + + tag = args.tag + + # Get Gemini highlights + prev_tag = get_previous_tag(tag) + print(f"INFO: Found previous tag: {prev_tag}") + titles = get_commit_titles(prev_tag, tag) + + highlights = generate_highlights(titles) + + # Get default GitHub notes + print("INFO: Fetching default GitHub release notes...") + default_notes = "" + try: + default_notes = subprocess.run( + ["gh", "release", "generate-notes", "--tag", tag, "--repo", "kubernetes-sigs/agent-sandbox"], + check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + except Exception as e: + print(f"⚠️ Error generating default notes: {e}") + + installation_notes = f""" +### Installation + +#### Core & Extensions +```bash +# To install only the core components: +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/{tag}/manifest.yaml + +# To install the extensions components: +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/{tag}/extensions.yaml +``` + +#### Python SDK +```bash +pip install k8s-agent-sandbox=={tag.lstrip('v')} +``` +""" + + combined_content = f"# 🚀 Announcing Agent Sandbox {tag}!\nWe are excited to announce the release of Agent Sandbox {tag}!\n\n" + if highlights: + combined_content += highlights + "\n\n" + + combined_content += installation_notes + "\n\n" + + if default_notes: + combined_content += default_notes + + if combined_content: + print(f"INFO: Saving release notes to {args.output}...") + with open(args.output, 'w') as f: + f.write(combined_content) + print("\n--- Preview ---\n") + print(combined_content) + else: + print("❌ No notes could be generated.") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/dev/tools/release b/dev/tools/release index 44ce558a4..c8b829488 100755 --- a/dev/tools/release +++ b/dev/tools/release @@ -19,6 +19,8 @@ import re import argparse import subprocess import sys +import json +import urllib.request def run_command(cmd): """Helper to run shell commands.""" @@ -28,6 +30,8 @@ def run_command(cmd): print(f"Error running command: {' '.join(cmd)}") sys.exit(1) + + def main(): parser = argparse.ArgumentParser(description='Generate release manifests.') parser.add_argument('--tag', required=True, help='The git tag for the release.') @@ -93,21 +97,43 @@ def main(): print("❌ Release assets missing. Cannot publish.") sys.exit(1) + # Generate release notes using the external standalone script + notes_file = "release_assets/release_notes.md" + print(f"INFO: Generating release notes using dev/tools/generate-release-notes...") + try: + subprocess.run( + ["./dev/tools/generate-release-notes", "--tag", tag, "--output", notes_file], + check=True + ) + print(f"INFO: Release notes generated at {notes_file}") + except subprocess.CalledProcessError as e: + print(f"⚠️ Error generating release notes with script: {e}") + print("⚠️ Falling back to default GitHub notes on release creation.") + notes_file = None + # Check if release exists, if so, we might want to fail or edit. # Here we assume creating a new one. cmd = [ "gh", "release", "create", tag, manifest_path, extensions_path, - "--generate-notes", + ] + + if notes_file: + cmd.extend(["--notes-file", notes_file]) + else: + cmd.append("--generate-notes") + + cmd.extend([ "--title", tag, "--verify-tag", "--draft", "--repo", "kubernetes-sigs/agent-sandbox" - ] + ]) + run_command(cmd) print(f"🎉 Draft release {tag} published successfully!") print(f"👉 Go to https://github.com/kubernetes-sigs/agent-sandbox/releases to review and publish.") if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/dev/tools/tag-promote-images b/dev/tools/tag-promote-images index a2a6b5b17..784f0b9fd 100755 --- a/dev/tools/tag-promote-images +++ b/dev/tools/tag-promote-images @@ -35,9 +35,17 @@ IMAGES_TO_PROMOTE = [ ] # Git Remotes (Adjust if your remote names differ) -REMOTE_UPSTREAM = "upstream" REMOTE_FORK = "origin" +def get_remote_name(cwd=None): + """Returns 'upstream' if it exists as a remote, otherwise 'origin'.""" + try: + remotes = run_command(["git", "remote"], cwd=cwd, capture_output=True).splitlines() + if "upstream" in remotes: + return "upstream" + return "origin" + except Exception: + return "origin" # Fallback def get_gh_username(): """Fetches the authenticated GitHub username.""" @@ -92,13 +100,23 @@ def main(): print(f"✅ Authenticated as: {gh_user}") print(f"🚀 Starting promotion process for {tag}") + + remote_agent_sandbox = get_remote_name() + print(f"ℹ️ Detected remote for agent-sandbox: {remote_agent_sandbox}") # --- Pre-flight Check --- - check_local_repo_state(REMOTE_UPSTREAM) + check_local_repo_state(remote_agent_sandbox) + check_tag_exists(tag, remote=remote_agent_sandbox) + + # --- Step 1: Create and Push Tag --- + print(f"--- Step 1: Handling Git Tag {tag} and Publish to PyPI---") + create_and_push_tag(tag, remote=remote_agent_sandbox) + + print("✅ Done! The 'pypi-publish' GitHub Action should now be running.") - # --- Step 1: Wait for Staging Images --- + # --- Step 2: Wait for Staging Images --- # CI job: https://prow.k8s.io/job-history/gs/kubernetes-ci-logs/logs/post-agent-sandbox-push-images - print(f"--- Step 1: Waiting for Staging Images ---") + print(f"--- Step 2: Waiting for Staging Images ---") print("⏳ Polling registry (timeout: 45 mins)...") collected_digests = {} @@ -126,11 +144,13 @@ def main(): print(" Please stash or commit them before running the release script.") sys.exit(1) - # Sync k8s.io repo from UPSTREAM - print(f"🔄 Syncing k8s.io main branch from {REMOTE_UPSTREAM}...") - run_command(["git", "fetch", REMOTE_UPSTREAM], cwd=k8s_io_dir) + # Sync k8s.io repo from remote + remote_k8s_io = get_remote_name(cwd=k8s_io_dir) + print(f"ℹ️ Detected remote for k8s.io: {remote_k8s_io}") + print(f"🔄 Syncing k8s.io main branch from {remote_k8s_io}...") + run_command(["git", "fetch", remote_k8s_io], cwd=k8s_io_dir) run_command(["git", "checkout", "main"], cwd=k8s_io_dir) - run_command(["git", "reset", "--hard", f"{REMOTE_UPSTREAM}/main"], cwd=k8s_io_dir) + run_command(["git", "reset", "--hard", f"{remote_k8s_io}/main"], cwd=k8s_io_dir) branch_name = f"promote-agent-sandbox-{tag}" # Cleanup old promotion branch if already exists, and create the new promotion branch From c7237aa20bbe35de303527daf98a87e28e1c75af Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 16 Apr 2026 16:20:47 +0000 Subject: [PATCH 5/9] Add SHA --- .github/workflows/ci.yml | 4 +-- .github/workflows/pypi-publish.yml | 41 ++++++------------------------ .github/workflows/release.yml | 14 +++++----- 3 files changed, 17 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca5607194..696941106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,6 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 - - uses: devops-actions/actionlint@v0.1.10 + - uses: devops-actions/actionlint@467e2ce19b2310e93c9ffa0b50fe31f86b5a7f23 # ratchet:devops-actions/actionlint@v0.1.10 \ No newline at end of file diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 88569dd0a..9c896e712 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -20,12 +20,12 @@ jobs: working-directory: clients/python/agentic-sandbox-client steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 with: fetch-depth: 0 # Important: Fetches all history and tags for setuptools-scm - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # ratchet:actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -48,10 +48,10 @@ jobs: working-directory: clients/python/agentic-sandbox-client steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # ratchet:actions/setup-python@v5 with: python-version: "3.10" @@ -63,41 +63,16 @@ jobs: run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4 with: name: python-package-distributions path: clients/python/agentic-sandbox-client/dist/ - publish-to-testpypi: - name: >- - Publish Python 🐍 distribution 📦 to TestPyPI - needs: - - build - runs-on: ubuntu-latest - environment: - name: testpypi - url: https://test.pypi.org/p/k8s-agent-sandbox - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing - contents: read - - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - name: Publish distribution 📦 to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI needs: - - publish-to-testpypi + - build runs-on: ubuntu-latest environment: name: pypi @@ -112,10 +87,10 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # ratchet:pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 982027ba6..03be5a652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout agent-sandbox - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 with: fetch-depth: 0 # To ensure pushing the tag during the workflow has the permission to trigger the PyPI publish workflow. @@ -37,7 +37,7 @@ jobs: token: ${{ secrets.GH_AUTOMATION_PAT }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 with: go-version-file: 'go.mod' @@ -56,13 +56,13 @@ jobs: git remote add origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" || git remote set-url origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # ratchet:google-github-actions/auth@v2 with: workload_identity_provider: '${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}' service_account: '${{ secrets.GCP_SERVICE_ACCOUNT }}' - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # ratchet:google-github-actions/setup-gcloud@v2 - name: Setup Git Identity run: | @@ -149,15 +149,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # ratchet:actions/setup-python@v5 with: python-version: '3.10' From 386274d783ed6c2e15c966dd41c24c72c581f1c5 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 16 Apr 2026 21:24:50 +0000 Subject: [PATCH 6/9] Fix lint --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03be5a652..48dea19b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,8 +74,8 @@ jobs: if: github.event.inputs.tag != '' id: set-tag run: | - echo "run_release=true" >> $GITHUB_OUTPUT - echo "new_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + echo "run_release=true" >> "$GITHUB_OUTPUT" + echo "new_tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" - name: Auto Tag if New Commits if: github.event.inputs.tag == '' From d305418287e0eccfff17edb32309ec9b4a3f2045 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 23 Apr 2026 16:18:27 +0000 Subject: [PATCH 7/9] Update release worklow, address comments --- .github/workflows/release.yml | 48 +++---- Makefile | 3 +- dev/tools/auto-tag | 13 +- dev/tools/generate-release-notes | 215 +++++++++++++++++++++++-------- dev/tools/release | 5 +- dev/tools/tag-promote-images | 38 ++++-- 6 files changed, 222 insertions(+), 100 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48dea19b5..6c36c5e68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,13 @@ name: Scheduled Release Automation on: # Version tag is incremented by patch update - schedule: - - cron: '0 6 * * 5' # Every Friday at 06:00 UTC + # schedule: + # - cron: '0 6 * * 5' # Every Friday at 06:00 UTC # For Major/Minor version updates, enter tag manually workflow_dispatch: inputs: - tag: - description: 'Tag for release (e.g., v0.2.0). Leave blank for auto-patch.' + tag: + description: "Tag for release (e.g., v0.2.0). Leave blank for auto-patch." required: false type: string @@ -21,7 +21,7 @@ jobs: promote: name: Promote Images runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 180 outputs: pr_url: ${{ steps.extract-pr.outputs.pr_url }} run_release: ${{ steps.set-tag.outputs.run_release || steps.auto-tag.outputs.run_release }} @@ -32,14 +32,14 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 with: fetch-depth: 0 - # To ensure pushing the tag during the workflow has the permission to trigger the PyPI publish workflow. + # To ensure pushing the tag during the workflow has the permission to trigger the PyPI publish workflow. # Github secret has repo access. token: ${{ secrets.GH_AUTOMATION_PAT }} - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Clone k8s.io env: @@ -56,13 +56,14 @@ jobs: git remote add origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" || git remote set-url origin "https://x-access-token:${{ secrets.K8S_IO_PAT }}@github.com/$GH_USER/k8s.io.git" - name: Authenticate to Google Cloud - uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # ratchet:google-github-actions/auth@v2 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 with: - workload_identity_provider: '${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}' - service_account: '${{ secrets.GCP_SERVICE_ACCOUNT }}' + credentials_json: ${{ secrets.GCP_SA_KEY }} + # workload_identity_provider: '${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}' + # service_account: '${{ secrets.GCP_SERVICE_ACCOUNT }}' - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # ratchet:google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@v2 - name: Setup Git Identity run: | @@ -74,8 +75,8 @@ jobs: if: github.event.inputs.tag != '' id: set-tag run: | - echo "run_release=true" >> "$GITHUB_OUTPUT" - echo "new_tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + echo "run_release=true" >> $GITHUB_OUTPUT + echo "new_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - name: Auto Tag if New Commits if: github.event.inputs.tag == '' @@ -92,14 +93,14 @@ jobs: run: | # Run promotion script TAG="${{ steps.set-tag.outputs.new_tag || steps.auto-tag.outputs.new_tag }}" - make release-promote TAG="$TAG" K8S_IO_DIR=../k8s.io - + make release-promote TAG="$TAG" K8S_IO_DIR=../k8s.io REMOTE_UPSTREAM=upstream REMOTE_FORK=origin + # Read the PR URL from the file generated by the script if [ ! -f promote_pr.url ]; then echo "❌ promote_pr.url file not found. Check make release-promote output." exit 1 fi - + PR_URL=$(cat promote_pr.url) echo "Found PR: $PR_URL" echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" @@ -119,7 +120,7 @@ jobs: PR_URL: ${{ needs.promote.outputs.pr_url }} run: | echo "Waiting for $PR_URL to be merged..." - + # 144 attempts, every 5 minutes = 12 hours total for i in {1..144}; do # Explicitly check the kubernetes/k8s.io repo context @@ -137,7 +138,7 @@ jobs: sleep 300 done - + echo "❌ Timed out waiting for merge." exit 1 @@ -146,20 +147,23 @@ jobs: needs: [promote, poll-merge] if: needs.promote.outputs.run_release == 'true' runs-on: ubuntu-latest - + steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4 + with: + ref: refs/tags/${{ needs.promote.outputs.new_tag }} + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # ratchet:actions/setup-python@v5 with: - python-version: '3.10' + python-version: "3.10" - name: Generate Draft Release and Manifests env: @@ -167,4 +171,4 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: | # This finally publishes the draft for your review - make release-publish TAG="${{ needs.promote.outputs.new_tag }}" \ No newline at end of file + make release-publish TAG="${{ needs.promote.outputs.new_tag }}" diff --git a/Makefile b/Makefile index 8b1fbe896..6701abf83 100644 --- a/Makefile +++ b/Makefile @@ -65,13 +65,14 @@ K8S_IO_DIR ?= ../../kubernetes/k8s.io # Default remote (can be overriden: make release-publish REMOTE=upstream ...) REMOTE_UPSTREAM ?= upstream +REMOTE_FORK ?= origin # Promote all staging images to registry.k8s.io # Usage: make release-promote TAG=vX.Y.Z .PHONY: release-promote release-promote: @if [ -z "$(TAG)" ]; then echo "TAG is required (e.g., make release-promote TAG=vX.Y.Z)"; exit 1; fi - ./dev/tools/tag-promote-images --tag=${TAG} --k8s-io-dir=${K8S_IO_DIR} + ./dev/tools/tag-promote-images --tag=${TAG} --k8s-io-dir=${K8S_IO_DIR} --upstream-remote=${REMOTE_UPSTREAM} --fork-remote=${REMOTE_FORK} # Publish a draft release to GitHub # Usage: make release-publish TAG=vX.Y.Z diff --git a/dev/tools/auto-tag b/dev/tools/auto-tag index d1bf5db71..cdb233e39 100755 --- a/dev/tools/auto-tag +++ b/dev/tools/auto-tag @@ -16,9 +16,9 @@ import os import sys import re -import subprocess -from shared.git_ops import run_command, validate_tag, check_tag_exists, create_and_push_tag +# The directory containing this script is automatically in sys.path +from shared.git_ops import run_command def increment_patch(version_str): """Increments the patch version of a SemVer string (e.g., v0.1.0 -> v0.1.1).""" @@ -34,11 +34,10 @@ def main(): print("🔍 Checking for new commits since last release...") # Get latest tag - try: - latest_tag = run_command(["git", "describe", "--tags", "--abbrev=0", "--match=v*"], capture_output=True) - except Exception as e: - print(f"⚠️ Error running git describe: {e}") - print(" Assuming no tags exist or history is missing. Defaulting to v0.1.0 base.") + # We look for tags matching 'v*' + latest_tag = run_command(["git", "describe", "--tags", "--abbrev=0", "--match=v*"], capture_output=True, allow_error=True) + if not latest_tag: + print("⚠️ Unable to determine latest tag from git describe. Defaulting to v0.1.0 base.") latest_tag = "v0.1.0" print(f"Found latest tag: {latest_tag}") diff --git a/dev/tools/generate-release-notes b/dev/tools/generate-release-notes index c86f66f08..48299a337 100755 --- a/dev/tools/generate-release-notes +++ b/dev/tools/generate-release-notes @@ -19,60 +19,165 @@ import json import urllib.request import subprocess import argparse +import re + def get_previous_tag(current_tag): try: tags_out = subprocess.run( ["git", "tag", "--list", "v*", "--sort=-v:refname"], - check=True, text=True, stdout=subprocess.PIPE + check=True, + text=True, + stdout=subprocess.PIPE, ).stdout.splitlines() - + if current_tag in tags_out: idx = tags_out.index(current_tag) if idx + 1 < len(tags_out): return tags_out[idx + 1] - if len(tags_out) > 1: - return tags_out[1] + if len(tags_out) > 0: + return tags_out[0] return None except subprocess.CalledProcessError: return None -def get_commit_titles(prev_tag, current_tag): + +def get_commits(prev_tag, current_tag): range_str = f"{prev_tag}..{current_tag}" if prev_tag else current_tag try: - titles = subprocess.run( - ["git", "log", range_str, "--pretty=format:%s"], - check=True, text=True, stdout=subprocess.PIPE + raw_log = subprocess.run( + ["git", "log", range_str, "--pretty=format:---COMMIT---%n%s%n%b"], + check=True, + text=True, + stdout=subprocess.PIPE, ).stdout.strip() - return titles + + commits = [] + if raw_log: + chunks = raw_log.split("---COMMIT---\n") + for chunk in chunks: + if not chunk.strip(): + continue + lines = chunk.splitlines() + title = lines[0] + body = "\n".join(lines[1:]) + commits.append({"title": title, "body": body}) + return commits except subprocess.CalledProcessError: - return "" + return [] + + +def get_pr_numbers(commits): + """Extracts PR numbers from commits.""" + pr_numbers = [] + patterns = [r"\(#(\d+)\)$", r"Merge pull request #(\d+)"] + + for commit in commits: + title = commit["title"] + for pattern in patterns: + match = re.search(pattern, title) + if match: + pr_numbers.append(int(match.group(1))) + break + return list(set(pr_numbers)) + + +def get_breaking_changes(pr_numbers): + """Fetches breaking changes from PRs with specific label.""" + breaking_changes = [] + for pr in pr_numbers: + try: + out = subprocess.run( + [ + "gh", + "pr", + "view", + str(pr), + "--json", + "labels,body", + "--repo", + "kubernetes-sigs/agent-sandbox", + ], + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout + data = json.loads(out) + labels = [l["name"] for l in data.get("labels", [])] + + if "release-note-action-required" in labels: + body = data.get("body", "").strip() + if body: + breaking_changes.append(f"PR #{pr}: {body}") + except subprocess.CalledProcessError: + print(f"⚠️ Warning: Failed to fetch PR #{pr} details.") + continue + return breaking_changes -def generate_highlights(titles): - api_key = os.environ.get("GEMINI_API_KEY") + +def get_pr_descriptions(pr_numbers): + """Fetches PR descriptions for given PR numbers.""" + pr_descriptions = [] + for pr in pr_numbers: + try: + out = subprocess.run( + ["gh", "pr", "view", str(pr), "--json", "body", "--repo", "kubernetes-sigs/agent-sandbox"], + check=True, text=True, stdout=subprocess.PIPE + ).stdout + data = json.loads(out) + body = data.get("body", "").strip() + if body: + pr_descriptions.append(f"PR #{pr}: {body}") + except subprocess.CalledProcessError: + print(f"⚠️ Warning: Failed to fetch PR #{pr} description.") + continue + return pr_descriptions + + +def generate_highlights(commits, breaking_changes, pr_descriptions, tag): + api_key = os.environ.get("GEMINI_API_KEY", "").strip() if not api_key: print("⚠️ GEMINI_API_KEY not set. Skipping Gemini highlights.") return None - + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}" - - prompt = f"You are a release assistant. Please summarize the following commit titles into a professional release note. Include an introductory paragraph highlighting the main achievements of this release, followed by a section called '### Key Highlights' with a bulleted list grouped logically. Keep it concise and focused on user-visible changes.\n\n{titles}" - - data = { - "contents": [{ - "parts": [{"text": prompt}] - }] - } - + + commit_titles = "\n".join([c["title"] for c in commits]) + commit_messages = "\n".join([f"- {c['title']}\n {c['body']}" for c in commits]) + pr_desc_str = "\n".join([f"- {desc}" for desc in pr_descriptions]) + + prompt = f"""**Task:** Generate curated release notes for **Agent Sandbox release draft** {tag} from the provided information, following the structure and tone of the ideal example below. + +**Instructions:** +- Generate only the **Key Highlights**, **Contributors**, and **New Contributors** sections. +- Summarize the highlights into a few key areas with bold titles describing the feature area (e.g., **Python SDK Advancements**, **Core Stability**). +- Synthesize information from full commit messages and PR descriptions to describe changes accurately. +- List contributors as handles (e.g., @handle), extracting them from the provided data. + +Input Commit Titles: +{commit_titles} + +Input Full Commit Messages: +{commit_messages} + +Input PR Descriptions: +{pr_desc_str} + +Breaking Changes: +{breaking_changes} +""" + + data = {"contents": [{"parts": [{"text": prompt}]}]} + req = urllib.request.Request( url, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"}, - method="POST" + method="POST", ) - + try: - with urllib.request.urlopen(req) as response: + with urllib.request.urlopen(req, timeout=30) as response: resp_data = json.loads(response.read().decode("utf-8")) candidates = resp_data.get("candidates", []) if candidates: @@ -81,35 +186,36 @@ def generate_highlights(titles): return parts[0].get("text") return None except Exception as e: - print(f"⚠️ Error calling Gemini API: {e}") + print(f"⚠️ Error calling Gemini API ({type(e).__name__}). Skipping Gemini highlights.") return None + def main(): - parser = argparse.ArgumentParser(description='Generate and preview release notes.') - parser.add_argument('--tag', required=True, help='The target git tag for the release.') - parser.add_argument('--output', default='release_notes_preview.md', help='File to save notes to.') + parser = argparse.ArgumentParser(description="Generate and preview release notes.") + parser.add_argument( + "--tag", required=True, help="The target git tag for the release." + ) + parser.add_argument( + "--output", default="release_notes_preview.md", help="File to save notes to." + ) args = parser.parse_args() tag = args.tag - # Get Gemini highlights prev_tag = get_previous_tag(tag) print(f"INFO: Found previous tag: {prev_tag}") - titles = get_commit_titles(prev_tag, tag) - - highlights = generate_highlights(titles) - - # Get default GitHub notes - print("INFO: Fetching default GitHub release notes...") - default_notes = "" - try: - default_notes = subprocess.run( - ["gh", "release", "generate-notes", "--tag", tag, "--repo", "kubernetes-sigs/agent-sandbox"], - check=True, text=True, stdout=subprocess.PIPE - ).stdout.strip() - except Exception as e: - print(f"⚠️ Error generating default notes: {e}") - + commits = get_commits(prev_tag, tag) + + # Extract PR numbers and breaking changes + pr_numbers = get_pr_numbers(commits) + breaking_changes = get_breaking_changes(pr_numbers) + pr_descriptions = get_pr_descriptions(pr_numbers) + + # Generate highlights using breaking changes and PR descriptions + highlights = generate_highlights( + commits, "\n".join(breaking_changes), pr_descriptions, tag + ) + installation_notes = f""" ### Installation @@ -128,23 +234,22 @@ pip install k8s-agent-sandbox=={tag.lstrip('v')} ``` """ - combined_content = f"# 🚀 Announcing Agent Sandbox {tag}!\nWe are excited to announce the release of Agent Sandbox {tag}!\n\n" + combined_content = f"# 🚀 Announcing Agent Sandbox {tag}!\n" if highlights: combined_content += highlights + "\n\n" - - combined_content += installation_notes + "\n\n" - - if default_notes: - combined_content += default_notes - + combined_content += installation_notes + "\n\n" + else: + combined_content += installation_notes + "\n\n" + if combined_content: print(f"INFO: Saving release notes to {args.output}...") - with open(args.output, 'w') as f: + with open(args.output, "w") as f: f.write(combined_content) print("\n--- Preview ---\n") print(combined_content) else: print("❌ No notes could be generated.") -if __name__ == '__main__': - main() \ No newline at end of file + +if __name__ == "__main__": + main() diff --git a/dev/tools/release b/dev/tools/release index c8b829488..3622f2f44 100755 --- a/dev/tools/release +++ b/dev/tools/release @@ -19,8 +19,6 @@ import re import argparse import subprocess import sys -import json -import urllib.request def run_command(cmd): """Helper to run shell commands.""" @@ -121,11 +119,10 @@ def main(): if notes_file: cmd.extend(["--notes-file", notes_file]) - else: - cmd.append("--generate-notes") cmd.extend([ "--title", tag, + "--generate-notes", "--verify-tag", "--draft", "--repo", "kubernetes-sigs/agent-sandbox" diff --git a/dev/tools/tag-promote-images b/dev/tools/tag-promote-images index 784f0b9fd..57ecbf56e 100755 --- a/dev/tools/tag-promote-images +++ b/dev/tools/tag-promote-images @@ -34,18 +34,29 @@ IMAGES_TO_PROMOTE = [ "python-runtime-sandbox" ] -# Git Remotes (Adjust if your remote names differ) -REMOTE_FORK = "origin" +# Git Remotes defaults are controlled by CLI arguments -def get_remote_name(cwd=None): - """Returns 'upstream' if it exists as a remote, otherwise 'origin'.""" +def get_remote_name(cwd=None, preferred_remote=None): + """Returns the preferred remote if specified and exists, or 'upstream' if it exists. + Falls back to 'origin' if neither are found. + Raises an exception if no valid remote is found. + """ try: remotes = run_command(["git", "remote"], cwd=cwd, capture_output=True).splitlines() + if preferred_remote and preferred_remote in remotes: + return preferred_remote + if "upstream" in remotes: return "upstream" - return "origin" - except Exception: - return "origin" # Fallback + + if "origin" in remotes: + return "origin" + + raise Exception( + f"Could not find any valid git remote. Available remotes: {remotes}. " + ) + except Exception as e: + raise Exception(f"Failed to determine git remote: {e}") def get_gh_username(): """Fetches the authenticated GitHub username.""" @@ -89,7 +100,12 @@ def main(): parser = argparse.ArgumentParser(description='Stage 1: Tag and Promote Agent Sandbox Release') parser.add_argument('--tag', required=True, help='The git tag for the release (e.g., v0.1.0)') parser.add_argument('--k8s-io-dir', default=DEFAULT_K8S_IO_DIR, help='Path to local k8s.io repository') + parser.add_argument('--upstream-remote', default='upstream', help='Remote name for upstream repository') + parser.add_argument('--fork-remote', default='origin', help='Remote name for fork repository') args = parser.parse_args() + + upstream_remote = args.upstream_remote + fork_remote = args.fork_remote tag = args.tag validate_tag(tag) @@ -101,7 +117,7 @@ def main(): print(f"🚀 Starting promotion process for {tag}") - remote_agent_sandbox = get_remote_name() + remote_agent_sandbox = get_remote_name(preferred_remote=upstream_remote) print(f"ℹ️ Detected remote for agent-sandbox: {remote_agent_sandbox}") # --- Pre-flight Check --- @@ -145,7 +161,7 @@ def main(): sys.exit(1) # Sync k8s.io repo from remote - remote_k8s_io = get_remote_name(cwd=k8s_io_dir) + remote_k8s_io = get_remote_name(cwd=k8s_io_dir, preferred_remote=upstream_remote) print(f"ℹ️ Detected remote for k8s.io: {remote_k8s_io}") print(f"🔄 Syncing k8s.io main branch from {remote_k8s_io}...") run_command(["git", "fetch", remote_k8s_io], cwd=k8s_io_dir) @@ -199,8 +215,8 @@ def main(): run_command(["git", "commit", "-m", f"Promote agent-sandbox {tag}"], cwd=k8s_io_dir) # Force push to ORIGIN (fork) to open PR (we force push to retry failed attempts) - print(f"⬆️ Pushing branch to {REMOTE_FORK}...") - run_command(["git", "push", "--force", "-u", REMOTE_FORK, branch_name], cwd=k8s_io_dir) + print(f"⬆️ Pushing branch to {fork_remote}...") + run_command(["git", "push", "--force", "-u", fork_remote, branch_name], cwd=k8s_io_dir) print("🚀 Creating PR...") head_branch_ref = f"{gh_user}:{branch_name}" From b96235ce5e3453614a61e6ca3ce347645c1749ce Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 23 Apr 2026 16:25:52 +0000 Subject: [PATCH 8/9] fix: double quotes --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c36c5e68..1e7b07dc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,8 +75,8 @@ jobs: if: github.event.inputs.tag != '' id: set-tag run: | - echo "run_release=true" >> $GITHUB_OUTPUT - echo "new_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + echo "run_release=true" >> "$GITHUB_OUTPUT" + echo "new_tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" - name: Auto Tag if New Commits if: github.event.inputs.tag == '' From fb93e38d38c78f3202ea88cdee3c26005cfe8011 Mon Sep 17 00:00:00 2001 From: Shrutiya Date: Thu, 23 Apr 2026 23:55:22 +0000 Subject: [PATCH 9/9] Remove unnecessary submodule computer-use-preview --- examples/gemini-cu-sandbox/computer-use-preview | 1 - 1 file changed, 1 deletion(-) delete mode 160000 examples/gemini-cu-sandbox/computer-use-preview diff --git a/examples/gemini-cu-sandbox/computer-use-preview b/examples/gemini-cu-sandbox/computer-use-preview deleted file mode 160000 index 8e2eb3041..000000000 --- a/examples/gemini-cu-sandbox/computer-use-preview +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8e2eb3041716f8b45791409c9190ff36bbf069fd