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

mimaison pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 6455a7b8575 KAFKA-19686: Trigger docker builds with rc and release in 
release script (#21963)
6455a7b8575 is described below

commit 6455a7b85750e3341361d1eb5c7064e7ec6a30ba
Author: Murali Basani <[email protected]>
AuthorDate: Mon Jul 27 11:51:39 2026 +0200

    KAFKA-19686: Trigger docker builds with rc and release in release script 
(#21963)
    
    - new file `release/gh_actions.py` wraps gh actions using pygithub
    - new `trigger_docker_workflows()` in `release/release.py`, invoked
    after `git.push_ref(rc_tag)` (happens in 2 steps. build and push)
    - gh personal access token is cached in `.release-settings.json`
    - token generation steps are in readme
    - reqs adds new import PyGithub
    
    Reviewers: Mickael Maison <[email protected]>
---
 release/README.md                          |  33 +++
 release/gh_actions.py                      |  95 +++++++
 release/release.py                         |  45 ++++
 release/requirements.txt                   |   1 +
 release/templates.py                       |  16 +-
 release/test_docker_trigger_interactive.py | 104 ++++++++
 release/test_gh_actions.py                 | 407 +++++++++++++++++++++++++++++
 7 files changed, 699 insertions(+), 2 deletions(-)

diff --git a/release/README.md b/release/README.md
index bde487fff35..42b5236db6f 100644
--- a/release/README.md
+++ b/release/README.md
@@ -57,3 +57,36 @@ Should you encounter some problem, where re-running the 
script doesn't work, loo
 `.release-settings.json` file in the `release` folder.
 - If the script is interrupted you might need to manually delete the tag named 
after the release candidate name and
 branch named after the release version.
+
+# Docker workflow triggers
+
+After the RC tag is pushed, the script triggers the Docker image build/test and
+RC release workflows on GitHub Actions.
+
+## GitHub Personal Access Token
+
+Triggering the workflows requires a GitHub Personal Access Token. To generate 
one:
+
+1. Go to https://github.com/settings/tokens
+2. Click "Generate new token" → "Generate new token (classic)"
+3. Set a name (e.g. `kafka-release`)
+4. Set an expiration (7 days is sufficient for a release cycle)
+5. Select the `repo` scope (this includes the `actions` write permission)
+6. Click "Generate token" and copy the token (starts with `ghp_...`)
+
+The token is cached in `.release-settings.json` so it only needs to be entered
+once per release cycle. To reset the saved token, remove the `github_token`
+entry from `.release-settings.json` or delete the file entirely.
+
+## Optional environment variables
+
+- `GITHUB_REPO`: target repository for the workflow dispatches. Defaults to
+  `apache/kafka`. Set this to your fork (e.g. `myuser/kafka`) when testing the
+  release script end-to-end without affecting `apache/kafka`.
+- `GITHUB_DRY_RUN`: when set to `true`, prints the GitHub API calls that would
+  be made instead of executing them. Useful for verifying the flow without a
+  token or network access.
+
+```
+GITHUB_DRY_RUN=true GITHUB_REPO=myuser/kafka python release.py
+```
diff --git a/release/gh_actions.py b/release/gh_actions.py
new file mode 100644
index 00000000000..df01d7ca9a4
--- /dev/null
+++ b/release/gh_actions.py
@@ -0,0 +1,95 @@
+#
+# 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.
+#
+
+"""
+Auxiliary functions to interact with the GitHub REST API via PyGithub.
+
+Set the GITHUB_REPO environment variable to override the target repository
+(e.g. "myuser/kafka" to test against a personal fork).
+
+Set GITHUB_DRY_RUN=true to print API calls without executing them.
+"""
+
+import json
+import os
+import time
+
+from github import Github, GithubException
+
+from runtime import fail
+
+GITHUB_REPO = os.environ.get("GITHUB_REPO", "apache/kafka")
+DRY_RUN = os.environ.get("GITHUB_DRY_RUN", "").lower() in ("true", "1", "yes")
+
+
+def _latest_run_url(workflow, workflow_file):
+    """
+    Return the HTML URL of the most recent run for a workflow,
+    falling back to the workflow's runs page on any error.
+    """
+    fallback = 
f"https://github.com/{GITHUB_REPO}/actions/workflows/{workflow_file}";
+    try:
+        runs = workflow.get_runs()
+        if runs.totalCount > 0:
+            return runs[0].html_url
+    except GithubException:
+        pass
+    return fallback
+
+
+def trigger_workflow(token, workflow_file, ref, inputs):
+    """
+    Trigger a GitHub Actions workflow_dispatch event.
+    """
+    print(f"Triggering {workflow_file} on {GITHUB_REPO} (ref={ref}) with 
inputs: {json.dumps(inputs)}")
+
+    if DRY_RUN:
+        print(f"  [DRY RUN] No API call made.")
+        print(f"  View runs: 
https://github.com/{GITHUB_REPO}/actions/workflows/{workflow_file}";)
+        return
+
+    try:
+        workflow = 
Github(token).get_repo(GITHUB_REPO).get_workflow(workflow_file)
+        if not workflow.create_dispatch(ref=ref, inputs=inputs):
+            fail(f"GitHub API failed to dispatch {workflow_file}")
+    except GithubException as e:
+        fail(f"GitHub API error {e.status} for workflow {workflow_file}: 
{e.data}")
+
+    # Brief pause to allow GitHub to register the run before querying
+    time.sleep(2)
+    print(f"  View run: {_latest_run_url(workflow, workflow_file)}")
+
+
+def trigger_docker_build_test(token, ref, image_type, kafka_url):
+    """
+    Trigger the Docker Build Test workflow for the given image type.
+    """
+    trigger_workflow(token, "docker_build_and_test.yml", ref, {
+        "image_type": image_type,
+        "kafka_url": kafka_url,
+    })
+
+
+def trigger_docker_rc_release(token, ref, image_type, rc_docker_image, 
kafka_url):
+    """
+    Trigger the Docker RC Release workflow for the given image type.
+    """
+    trigger_workflow(token, "docker_rc_release.yml", ref, {
+        "image_type": image_type,
+        "rc_docker_image": rc_docker_image,
+        "kafka_url": kafka_url,
+    })
diff --git a/release/release.py b/release/release.py
index bc8fc231e05..d5b8749f518 100644
--- a/release/release.py
+++ b/release/release.py
@@ -67,6 +67,7 @@ from runtime import (
     repo_dir,
 )
 import git
+import gh_actions
 import gpg
 import notes
 import preferences
@@ -217,6 +218,48 @@ elif not (subcommand is None or subcommand == 'stage'):
 ## Default 'stage' subcommand implementation isn't isolated to its own 
function yet for historical reasons
 
 
+def trigger_docker_workflows(rc_tag, release_version, dev_branch):
+    """
+    Trigger Docker image build/test and RC release workflows via GitHub 
Actions API.
+    Prompts the user for confirmation before each step.
+    """
+    print("\n=== Docker Image Workflows ===")
+    if gh_actions.DRY_RUN:
+        print("NOTE: GITHUB_DRY_RUN is enabled. No actual API calls will be 
made.")
+    if gh_actions.GITHUB_REPO != "apache/kafka":
+        print(f"NOTE: Using custom repository: {gh_actions.GITHUB_REPO}")
+    if not confirm("Trigger Docker image build workflows via GitHub Actions?"):
+        print("Skipping Docker image workflows.")
+        return
+
+    def get_github_token():
+        print(templates.github_token_instructions())
+        return prompt("Enter your GitHub personal access token: ")
+    github_token = preferences.get('github_token', get_github_token)
+    kafka_url = 
f"https://dist.apache.org/repos/dist/dev/kafka/{rc_tag}/kafka_2.13-{release_version}.tgz";
+
+    # Step 1: Trigger build/test workflows and loop until CVE-free
+    while True:
+        print("\nStep 1/2: Triggering Docker Build Test workflows for JVM and 
native images...")
+        for image_type in ["jvm", "native"]:
+            gh_actions.trigger_docker_build_test(github_token, dev_branch, 
image_type, kafka_url)
+        print(f"\nReview build results and CVE scan reports at:")
+        print(f"  
https://github.com/{gh_actions.GITHUB_REPO}/actions/workflows/docker_build_and_test.yml";)
+        print("Both JVM and native builds should succeed with no CRITICAL or 
HIGH CVEs.")
+        print("If CVEs are found, update docker/jvm/Dockerfile or 
docker/native/Dockerfile and answer 'n' below to retry.")
+        if confirm("Have the builds passed with no CVEs?"):
+            break
+
+    # Step 2: Push RC images to DockerHub
+    print("\nStep 2/2: Triggering Docker RC Release workflows for JVM and 
native images...")
+    for image_type in ["jvm", "native"]:
+        docker_image_name = "apache/kafka-native" if image_type == "native" 
else "apache/kafka"
+        rc_docker_image = f"{docker_image_name}:{rc_tag}"
+        gh_actions.trigger_docker_rc_release(github_token, dev_branch, 
image_type, rc_docker_image, kafka_url)
+
+    print(f"\nMonitor all Docker workflow runs at: 
https://github.com/{gh_actions.GITHUB_REPO}/actions";)
+
+
 def verify_gpg_key():
     if not gpg.key_exists(gpg_key_id):
         fail(f"GPG key {gpg_key_id} not found")
@@ -380,6 +423,8 @@ confirm_or_fail(f"Ok to push RC tag {rc_tag}?")
 git.push_ref(rc_tag)
 git.push_ref(starting_branch)
 
+trigger_docker_workflows(rc_tag, release_version, dev_branch)
+
 # Move back to starting branch and clean out the temporary release branch 
(e.g. 1.0.0) we used to generate everything
 git.reset_hard_head()
 git.switch_branch(starting_branch)
diff --git a/release/requirements.txt b/release/requirements.txt
index 905292d09c2..7cb10841186 100644
--- a/release/requirements.txt
+++ b/release/requirements.txt
@@ -17,3 +17,4 @@
 
 jira==3.8.0
 jproperties==2.1.1
+PyGithub==2.4.0
diff --git a/release/templates.py b/release/templates.py
index aff2d33cb91..447e0e2aea2 100644
--- a/release/templates.py
+++ b/release/templates.py
@@ -154,8 +154,9 @@ Go to https://repository.apache.org/#stagingRepositories 
and hit 'Close' for the
 There will be more than one repository entries created, please close all of 
them.
 In some cases, you may get errors on some repositories while closing them, see 
KAFKA-15033.
 If this is not the first RC, you need to 'Drop' the previous artifacts.
-Confirm the correct artifacts are visible at 
https://repository.apache.org/content/groups/staging/org/apache/kafka/ and 
build the
-jvm and native Docker images following these instructions: 
https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=34840886#ReleaseProcess-CreateJVMApacheKafkaDockerArtifacts(Forversions>=3.7.0)
+Confirm the correct artifacts are visible at 
https://repository.apache.org/content/groups/staging/org/apache/kafka/
+Docker image builds will be triggered automatically by this script after the 
RC tag is pushed.
+If the automation fails, fall back to the manual procedure: 
https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=34840886#ReleaseProcess-CreateJVMApacheKafkaDockerArtifacts(Forversions>=3.7.0)
 """
 
 def sanity_check_instructions(release_version, rc_tag):
@@ -267,6 +268,17 @@ Note that all substitutions are annotated with <> around 
them.
 """
 
 
+def github_token_instructions():
+    return """
+A GitHub Personal Access Token with `repo` scope is required to trigger the
+Docker image workflows. See the "GitHub Personal Access Token" section of
+release/README.md for step-by-step generation instructions.
+
+The token will be cached in .release-settings.json so it only needs to be
+entered once per release cycle.
+"""
+
+
 def cmd_failed():
     return """
 *************************************************
diff --git a/release/test_docker_trigger_interactive.py 
b/release/test_docker_trigger_interactive.py
new file mode 100644
index 00000000000..1fdd700a33c
--- /dev/null
+++ b/release/test_docker_trigger_interactive.py
@@ -0,0 +1,104 @@
+#
+# 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.
+#
+
+"""
+Interactive test script for the Docker workflow trigger flow.
+
+This invokes the actual trigger_docker_workflows() function from release.py
+without needing GPG, SVN, Maven, or committer access. The function is
+extracted from release.py without executing its top-level interactive code.
+
+Usage:
+  # Dry-run (no API calls, no token needed — recommended for first test):
+  GITHUB_DRY_RUN=true python test_docker_trigger_interactive.py
+
+  # Against your fork (real API calls, needs a GitHub token):
+  GITHUB_REPO=yourusername/kafka python test_docker_trigger_interactive.py
+
+  # Combine both:
+  GITHUB_DRY_RUN=true GITHUB_REPO=yourusername/kafka python 
test_docker_trigger_interactive.py
+"""
+
+import os
+import sys
+
+# Ensure release/ is on the path
+sys.path.insert(0, os.path.dirname(__file__))
+
+from runtime import confirm, confirm_or_fail, prompt
+import gh_actions
+import preferences
+import templates
+
+
+def _load_trigger_docker_workflows():
+    """
+    Extract trigger_docker_workflows from release.py without executing the
+    module's top-level interactive code. We parse the source and compile just
+    the function definition, then bind it to real (not mocked) dependencies.
+    """
+    release_path = os.path.join(os.path.dirname(__file__), "release.py")
+    with open(release_path) as f:
+        source = f.read()
+
+    lines = source.split('\n')
+    func_lines = []
+    capturing = False
+    for line in lines:
+        if line.startswith('def trigger_docker_workflows('):
+            capturing = True
+        elif capturing and line and not line[0].isspace() and not 
line.startswith('#'):
+            break
+        if capturing:
+            func_lines.append(line)
+
+    func_source = '\n'.join(func_lines)
+
+    ns = {
+        'gh_actions': gh_actions,
+        'confirm': confirm,
+        'confirm_or_fail': confirm_or_fail,
+        'preferences': preferences,
+        'templates': templates,
+        'prompt': prompt,
+    }
+    exec(compile(func_source, release_path, 'exec'), ns)
+    return ns['trigger_docker_workflows']
+
+
+if __name__ == "__main__":
+    trigger_docker_workflows = _load_trigger_docker_workflows()
+
+    print("=" * 70)
+    print("  Docker Workflow Trigger - Interactive Test")
+    print("=" * 70)
+    print(f"\n  Target repo  : {gh_actions.GITHUB_REPO}")
+    print(f"  Dry-run mode : {gh_actions.DRY_RUN}")
+    print()
+
+    release_version = prompt("Enter release version (e.g. 4.3.0): ")
+    rc = prompt("Enter RC number (e.g. 0): ")
+    rc_tag = f"{release_version}-rc{rc}"
+    dev_branch = '.'.join(release_version.split('.')[:2])
+
+    print(f"\n  Release version : {release_version}")
+    print(f"  RC tag          : {rc_tag}")
+    print(f"  Dev branch      : {dev_branch}")
+
+    trigger_docker_workflows(rc_tag, release_version, dev_branch)
+
+    print("\nDone.")
diff --git a/release/test_gh_actions.py b/release/test_gh_actions.py
new file mode 100644
index 00000000000..982812f401d
--- /dev/null
+++ b/release/test_gh_actions.py
@@ -0,0 +1,407 @@
+#
+# 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.
+#
+
+"""
+Unit tests for the gh_actions module.
+Run with: python -m pytest release/test_gh_actions.py -v
+   or:    cd release && python -m pytest test_gh_actions.py -v
+"""
+
+import unittest
+from unittest.mock import patch, MagicMock
+
+from github import GithubException
+
+import gh_actions as gh
+
+
+def _mock_workflow(run_url=None, dispatch_ok=True):
+    """Build a mock PyGithub Workflow object."""
+    wf = MagicMock()
+    wf.create_dispatch.return_value = dispatch_ok
+
+    runs = MagicMock()
+    if run_url is None:
+        runs.totalCount = 0
+    else:
+        runs.totalCount = 1
+        run = MagicMock()
+        run.html_url = run_url
+        runs.__getitem__.side_effect = lambda i: run
+    wf.get_runs.return_value = runs
+    return wf
+
+
+def _patch_pygithub(workflow=None, raise_on_dispatch=None):
+    """
+    Patch gh_actions.Github so that .get_repo(...).get_workflow(...) returns 
the
+    given workflow mock. Returns the patcher context.
+    """
+    if workflow is None:
+        workflow = _mock_workflow()
+    if raise_on_dispatch is not None:
+        workflow.create_dispatch.side_effect = raise_on_dispatch
+
+    repo = MagicMock()
+    repo.get_workflow.return_value = workflow
+    gh_client = MagicMock()
+    gh_client.get_repo.return_value = repo
+    return patch("gh_actions.Github", return_value=gh_client), workflow, repo
+
+
+class TestTriggerWorkflow(unittest.TestCase):
+
+    def setUp(self):
+        self._orig_dry_run = gh.DRY_RUN
+        gh.DRY_RUN = False
+
+    def tearDown(self):
+        gh.DRY_RUN = self._orig_dry_run
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_trigger_workflow_calls_create_dispatch(self):
+        patcher, workflow, repo = _patch_pygithub(
+            
_mock_workflow(run_url="https://github.com/apache/kafka/actions/runs/123";))
+        with patcher as mock_gh:
+            gh.trigger_workflow("tok", "my_workflow.yml", "main", {"key": 
"val"})
+
+        mock_gh.assert_called_once_with("tok")
+        repo.get_workflow.assert_called_once_with("my_workflow.yml")
+        workflow.create_dispatch.assert_called_once_with(ref="main", 
inputs={"key": "val"})
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_trigger_workflow_uses_configured_repo(self):
+        self._orig_repo = gh.GITHUB_REPO
+        try:
+            gh.GITHUB_REPO = "myuser/kafka-fork"
+            patcher, workflow, repo = _patch_pygithub()
+            with patcher as mock_gh:
+                gh.trigger_workflow("tok", "my_workflow.yml", "main", {})
+            
mock_gh.return_value.get_repo.assert_called_once_with("myuser/kafka-fork")
+        finally:
+            gh.GITHUB_REPO = self._orig_repo
+
+    @patch("gh_actions.fail")
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_trigger_workflow_fails_on_github_exception(self, mock_fail):
+        err = GithubException(status=404, data={"message": "Not Found"}, 
headers={})
+        patcher, _, _ = _patch_pygithub(raise_on_dispatch=err)
+        with patcher:
+            gh.trigger_workflow("tok", "my_workflow.yml", "main", {})
+
+        mock_fail.assert_called_once()
+        self.assertIn("404", mock_fail.call_args[0][0])
+        self.assertIn("my_workflow.yml", mock_fail.call_args[0][0])
+
+    @patch("gh_actions.fail")
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_trigger_workflow_fails_when_dispatch_returns_false(self, 
mock_fail):
+        patcher, _, _ = _patch_pygithub(_mock_workflow(dispatch_ok=False))
+        with patcher:
+            gh.trigger_workflow("tok", "my_workflow.yml", "main", {})
+
+        mock_fail.assert_called_once()
+        self.assertIn("dispatch", mock_fail.call_args[0][0])
+
+
+class TestLatestRunUrl(unittest.TestCase):
+
+    def setUp(self):
+        self._orig_repo = gh.GITHUB_REPO
+        gh.GITHUB_REPO = "apache/kafka"
+
+    def tearDown(self):
+        gh.GITHUB_REPO = self._orig_repo
+
+    def test_returns_run_url_when_run_exists(self):
+        wf = 
_mock_workflow(run_url="https://github.com/apache/kafka/actions/runs/42";)
+        url = gh._latest_run_url(wf, "docker_build_and_test.yml")
+        self.assertEqual(url, 
"https://github.com/apache/kafka/actions/runs/42";)
+
+    def test_falls_back_when_no_runs(self):
+        wf = _mock_workflow(run_url=None)
+        url = gh._latest_run_url(wf, "docker_build_and_test.yml")
+        self.assertEqual(
+            url,
+            
"https://github.com/apache/kafka/actions/workflows/docker_build_and_test.yml";,
+        )
+
+    def test_falls_back_on_github_exception(self):
+        wf = MagicMock()
+        wf.get_runs.side_effect = GithubException(500, {}, {})
+        url = gh._latest_run_url(wf, "docker_build_and_test.yml")
+        self.assertEqual(
+            url,
+            
"https://github.com/apache/kafka/actions/workflows/docker_build_and_test.yml";,
+        )
+
+
+class TestDryRun(unittest.TestCase):
+
+    def setUp(self):
+        self._orig_dry_run = gh.DRY_RUN
+
+    def tearDown(self):
+        gh.DRY_RUN = self._orig_dry_run
+
+    def test_dry_run_skips_api_call(self):
+        gh.DRY_RUN = True
+        with patch("gh_actions.Github") as mock_gh:
+            gh.trigger_workflow("tok", "test.yml", "main", {"key": "val"})
+        mock_gh.assert_not_called()
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_dry_run_false_calls_api(self):
+        gh.DRY_RUN = False
+        patcher, workflow, _ = _patch_pygithub()
+        with patcher as mock_gh:
+            gh.trigger_workflow("tok", "test.yml", "main", {"key": "val"})
+        mock_gh.assert_called_once()
+        workflow.create_dispatch.assert_called_once()
+
+
+class TestTriggerDockerBuildTest(unittest.TestCase):
+
+    def setUp(self):
+        self._orig_dry_run = gh.DRY_RUN
+        gh.DRY_RUN = False
+
+    def tearDown(self):
+        gh.DRY_RUN = self._orig_dry_run
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_jvm_image(self):
+        patcher, workflow, repo = _patch_pygithub()
+        with patcher:
+            gh.trigger_docker_build_test("tok", "4.3", "jvm", 
"https://example.com/kafka.tgz";)
+
+        repo.get_workflow.assert_called_once_with("docker_build_and_test.yml")
+        workflow.create_dispatch.assert_called_once_with(
+            ref="4.3",
+            inputs={"image_type": "jvm", "kafka_url": 
"https://example.com/kafka.tgz"},
+        )
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_native_image(self):
+        patcher, workflow, repo = _patch_pygithub()
+        with patcher:
+            gh.trigger_docker_build_test("tok", "4.3", "native", 
"https://example.com/kafka.tgz";)
+
+        workflow.create_dispatch.assert_called_once_with(
+            ref="4.3",
+            inputs={"image_type": "native", "kafka_url": 
"https://example.com/kafka.tgz"},
+        )
+
+
+class TestTriggerDockerRcRelease(unittest.TestCase):
+
+    def setUp(self):
+        self._orig_dry_run = gh.DRY_RUN
+        gh.DRY_RUN = False
+
+    def tearDown(self):
+        gh.DRY_RUN = self._orig_dry_run
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_jvm_rc_release(self):
+        patcher, workflow, repo = _patch_pygithub()
+        with patcher:
+            gh.trigger_docker_rc_release(
+                "tok", "4.3", "jvm", "apache/kafka:4.3.0-rc0",
+                "https://example.com/kafka.tgz";,
+            )
+
+        repo.get_workflow.assert_called_once_with("docker_rc_release.yml")
+        workflow.create_dispatch.assert_called_once_with(
+            ref="4.3",
+            inputs={
+                "image_type": "jvm",
+                "rc_docker_image": "apache/kafka:4.3.0-rc0",
+                "kafka_url": "https://example.com/kafka.tgz";,
+            },
+        )
+
+    @patch("gh_actions.time.sleep", lambda _: None)
+    def test_native_rc_release(self):
+        patcher, workflow, repo = _patch_pygithub()
+        with patcher:
+            gh.trigger_docker_rc_release(
+                "tok", "4.3", "native", "apache/kafka-native:4.3.0-rc0",
+                "https://example.com/kafka.tgz";,
+            )
+
+        workflow.create_dispatch.assert_called_once_with(
+            ref="4.3",
+            inputs={
+                "image_type": "native",
+                "rc_docker_image": "apache/kafka-native:4.3.0-rc0",
+                "kafka_url": "https://example.com/kafka.tgz";,
+            },
+        )
+
+
+class TestWorkflowInputAlignment(unittest.TestCase):
+    """Verify that the inputs we send match what the workflow YAML files 
expect."""
+
+    def _load_workflow_inputs(self, workflow_file):
+        import yaml
+        import os
+        base = os.path.join(os.path.dirname(__file__), "..", ".github", 
"workflows")
+        with open(os.path.join(base, workflow_file)) as f:
+            wf = yaml.safe_load(f)
+        # PyYAML parses 'on' as boolean True
+        return set(wf[True]["workflow_dispatch"]["inputs"].keys())
+
+    def test_build_and_test_inputs_match(self):
+        expected = self._load_workflow_inputs("docker_build_and_test.yml")
+        sent = {"image_type", "kafka_url"}
+        self.assertEqual(sent, expected,
+            f"gh_actions.trigger_docker_build_test sends {sent} but workflow 
expects {expected}")
+
+    def test_rc_release_inputs_match(self):
+        expected = self._load_workflow_inputs("docker_rc_release.yml")
+        sent = {"image_type", "rc_docker_image", "kafka_url"}
+        self.assertEqual(sent, expected,
+            f"gh_actions.trigger_docker_rc_release sends {sent} but workflow 
expects {expected}")
+
+
+# We need to import trigger_docker_workflows from release.py, but that file
+# executes interactively at module level. So we import it directly from the
+# function definition using importlib to avoid running the top-level code.
+def _load_trigger_docker_workflows():
+    """
+    Extract trigger_docker_workflows from release.py without executing the
+    module's top-level interactive code. We parse the source and compile just
+    the function definition.
+    """
+    import os
+    release_path = os.path.join(os.path.dirname(__file__), "release.py")
+    with open(release_path) as f:
+        source = f.read()
+
+    lines = source.split('\n')
+    func_lines = []
+    capturing = False
+    for line in lines:
+        if line.startswith('def trigger_docker_workflows('):
+            capturing = True
+        elif capturing and line and not line[0].isspace() and not 
line.startswith('#'):
+            break
+        if capturing:
+            func_lines.append(line)
+
+    func_source = '\n'.join(func_lines)
+
+    ns = {
+        'gh_actions': gh,
+        'confirm': None,
+        'confirm_or_fail': None,
+        'preferences': None,
+        'templates': None,
+        'prompt': None,
+    }
+    exec(compile(func_source, release_path, 'exec'), ns)
+    return ns['trigger_docker_workflows'], ns
+
+
+_trigger_fn, _fn_namespace = _load_trigger_docker_workflows()
+
+
+class TestTriggerDockerWorkflows(unittest.TestCase):
+    """Test the trigger_docker_workflows function from release.py."""
+
+    def setUp(self):
+        self._orig_dry_run = gh.DRY_RUN
+        gh.DRY_RUN = True  # Always dry-run in tests to skip the PyGithub call
+
+    def tearDown(self):
+        gh.DRY_RUN = self._orig_dry_run
+
+    def _run(self, confirm_responses):
+        """
+        Run trigger_docker_workflows with mocked interactive prompts.
+        Returns the list of (workflow_file, ref, inputs) calls captured from
+        gh_actions.trigger_workflow.
+        """
+        confirm_iter = iter(confirm_responses)
+        _fn_namespace['confirm'] = lambda msg: next(confirm_iter)
+        _fn_namespace['confirm_or_fail'] = lambda msg: None
+        _fn_namespace['preferences'] = MagicMock()
+        _fn_namespace['preferences'].get = MagicMock(return_value="fake-token")
+        _fn_namespace['templates'] = MagicMock()
+        _fn_namespace['templates'].github_token_instructions = 
MagicMock(return_value="token instructions")
+        _fn_namespace['prompt'] = MagicMock(return_value="fake-token")
+
+        with patch("gh_actions.trigger_workflow") as mock_trigger:
+            _trigger_fn("4.3.0-rc0", "4.3.0", "4.3")
+            return [c[0] for c in mock_trigger.call_args_list]
+
+    def test_happy_path_all_yes(self):
+        # confirm calls: 1) trigger? yes, 2) builds passed? yes
+        calls = self._run([True, True])
+        self.assertEqual(len(calls), 4)
+        # First 2: build_and_test (jvm, native)
+        self.assertEqual(calls[0][1], "docker_build_and_test.yml")
+        self.assertEqual(calls[1][1], "docker_build_and_test.yml")
+        # Last 2: rc_release (jvm, native)
+        self.assertEqual(calls[2][1], "docker_rc_release.yml")
+        self.assertEqual(calls[3][1], "docker_rc_release.yml")
+
+    def test_skip_docker_workflows(self):
+        calls = self._run([False])
+        self.assertEqual(calls, [])
+
+    def test_cve_retry_then_pass(self):
+        # confirm: 1) trigger? yes, 2) passed? no, 3) passed? yes
+        calls = self._run([True, False, True])
+        # 2 build_test (1st) + 2 build_test (retry) + 2 rc_release = 6
+        self.assertEqual(len(calls), 6)
+        for i in range(4):
+            self.assertEqual(calls[i][1], "docker_build_and_test.yml")
+        self.assertEqual(calls[4][1], "docker_rc_release.yml")
+        self.assertEqual(calls[5][1], "docker_rc_release.yml")
+
+    def test_multiple_cve_retries(self):
+        # confirm: 1) trigger? yes, 2) no, 3) no, 4) yes
+        calls = self._run([True, False, False, True])
+        # 3 rounds of build_test (6) + 1 round of rc_release (2) = 8
+        self.assertEqual(len(calls), 8)
+
+    def test_rc_release_uses_correct_image_names(self):
+        calls = self._run([True, True])
+        # call[2] is RC release JVM, call[3] is RC release native
+        jvm_inputs = calls[2][3]
+        self.assertEqual(jvm_inputs["rc_docker_image"], 
"apache/kafka:4.3.0-rc0")
+        self.assertEqual(jvm_inputs["image_type"], "jvm")
+
+        native_inputs = calls[3][3]
+        self.assertEqual(native_inputs["rc_docker_image"], 
"apache/kafka-native:4.3.0-rc0")
+        self.assertEqual(native_inputs["image_type"], "native")
+
+    def test_kafka_url_construction(self):
+        calls = self._run([True, True])
+        expected_url = 
"https://dist.apache.org/repos/dist/dev/kafka/4.3.0-rc0/kafka_2.13-4.3.0.tgz";
+        self.assertEqual(calls[0][3]["kafka_url"], expected_url)
+
+    def test_dev_branch_used_as_ref(self):
+        calls = self._run([True, True])
+        for call in calls:
+            self.assertEqual(call[2], "4.3")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to