diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..c90adab
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,76 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ test:
+ name: Run Unit Tests
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ['3.10', '3.11', '3.12']
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+
+ - name: Run tests with pytest
+ run: |
+ pytest -v --cov=builders --cov-report=xml --cov-report=term
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ if: matrix.python-version == '3.11'
+ with:
+ file: ./coverage.xml
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ lint:
+ name: Code Quality
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 black
+
+ - name: Check code formatting with black
+ run: |
+ black --check --diff builders/ tests/
+ continue-on-error: true
+
+ - name: Lint with flake8
+ run: |
+ # Stop the build if there are Python syntax errors or undefined names
+ flake8 builders/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
+ # Exit-zero treats all errors as warnings
+ flake8 builders/ tests/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+ continue-on-error: true
diff --git a/requirements.txt b/requirements.txt
index 760e79b..96cf364 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,5 @@
PyGithub>=1.58.0
PyYAML>=5.1
+pytest>=7.4.0
+pytest-mock>=3.11.0
+pytest-cov>=4.1.0
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..3329e93
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,39 @@
+import pytest
+import os
+import tempfile
+import shutil
+
+
+@pytest.fixture
+def temp_repo_dir():
+ """Create a temporary directory for repository simulation."""
+ temp_dir = tempfile.mkdtemp()
+ yield temp_dir
+ shutil.rmtree(temp_dir, ignore_errors=True)
+
+
+@pytest.fixture
+def mock_subprocess_run(mocker):
+ """Mock subprocess.run to avoid actual command execution."""
+ return mocker.patch('subprocess.run')
+
+
+@pytest.fixture
+def mock_os_listdir(mocker):
+ """Mock os.listdir for directory content simulation."""
+ return mocker.patch('os.listdir')
+
+
+@pytest.fixture
+def mock_os_path_exists(mocker):
+ """Mock os.path.exists for file existence simulation."""
+ return mocker.patch('os.path.exists')
+
+
+@pytest.fixture
+def sample_artifact():
+ """Sample artifact configuration for testing."""
+ return {
+ "version": "1.0.0",
+ "type": "binary"
+ }
diff --git a/tests/test_go_binary_builder.py b/tests/test_go_binary_builder.py
new file mode 100644
index 0000000..4cc571a
--- /dev/null
+++ b/tests/test_go_binary_builder.py
@@ -0,0 +1,180 @@
+import pytest
+import os
+from unittest.mock import patch
+from builders.binary.go_binary_builder import GoBinaryBuilder
+
+
+class TestGoBinaryBuilder:
+ """Test GoBinaryBuilder build and publish methods."""
+
+ def test_build_success(self, temp_repo_dir, mocker):
+ """Test successful Go binary build."""
+ # Mock subprocess.run
+ mock_run = mocker.patch('subprocess.run')
+
+ # Build
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ result_path = builder.build(temp_repo_dir, "go-app", artifact)
+
+ # Assertions
+ expected_path = os.path.join(temp_repo_dir, "build", "go-app_1.0.0_s390x")
+ assert os.path.normpath(result_path) == os.path.normpath(expected_path)
+
+ # Verify Docker command was called
+ mock_run.assert_called_once()
+ docker_call = mock_run.call_args
+ assert "docker" in docker_call[0][0]
+ assert "run" in docker_call[0][0]
+ assert "--rm" in docker_call[0][0]
+ assert "ubuntu:22.04" in docker_call[0][0]
+ assert "go" in docker_call[0][0]
+ assert "build" in docker_call[0][0]
+ # Check that expected path appears in command (may have different separators)
+ assert any(os.path.normpath(expected_path) == os.path.normpath(arg) for arg in docker_call[0][0])
+
+ def test_build_with_custom_docker_image(self, temp_repo_dir, mocker):
+ """Test build with custom Docker image."""
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = GoBinaryBuilder()
+ artifact = {
+ "version": "2.0.0",
+ "docker_image": "golang:1.21-alpine"
+ }
+ builder.build(temp_repo_dir, "custom-go-app", artifact)
+
+ # Verify custom Docker image is used
+ docker_call = mock_run.call_args
+ assert "golang:1.21-alpine" in docker_call[0][0]
+
+ def test_build_default_version(self, temp_repo_dir, mocker):
+ """Test build with default version when not specified."""
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = GoBinaryBuilder()
+ artifact = {} # No version specified
+ result_path = builder.build(temp_repo_dir, "go-app", artifact)
+
+ # Should use default version 1.0
+ expected_path = os.path.join(temp_repo_dir, "build", "go-app_1.0_s390x")
+ assert os.path.normpath(result_path) == os.path.normpath(expected_path)
+
+ def test_build_subprocess_error(self, temp_repo_dir, mocker):
+ """Test build handles subprocess errors gracefully."""
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(
+ 1, 'docker', stderr=b'Docker error'
+ ))
+
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.build(temp_repo_dir, "error-app", artifact)
+
+ def test_build_creates_output_directory(self, temp_repo_dir, mocker):
+ """Test that build creates the build directory if it doesn't exist."""
+ mock_run = mocker.patch('subprocess.run')
+
+ # Verify build directory doesn't exist initially
+ build_dir = os.path.join(temp_repo_dir, "build")
+ assert not os.path.exists(build_dir)
+
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.build(temp_repo_dir, "go-app", artifact)
+
+ # Build directory should be created
+ # Note: In the actual code, this is done via os.makedirs("build", exist_ok=True)
+ # The mock prevents actual directory creation, so we just verify the call was made
+ mock_run.assert_called_once()
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_success(self, mock_checksum, temp_repo_dir, mocker):
+ """Test successful artifact publishing."""
+ # Setup
+ artifact_path = os.path.join(temp_repo_dir, "go-app_1.0.0_s390x")
+ with open(artifact_path, "w") as f:
+ f.write("fake binary")
+
+ mock_checksum.return_value = "def456"
+ mock_run = mocker.patch('subprocess.run')
+
+ # Publish
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "go-app", artifact)
+
+ # Assertions
+ mock_checksum.assert_called_once_with(artifact_path)
+ mock_run.assert_called_once()
+
+ # Verify gh release create command
+ gh_call = mock_run.call_args
+ assert "gh" in gh_call[0][0]
+ assert "release" in gh_call[0][0]
+ assert "create" in gh_call[0][0]
+ assert "v1.0.0" in gh_call[0][0]
+ assert "--title" in gh_call[0][0]
+ assert "Version 1.0.0" in gh_call[0][0]
+ assert "--generate-notes" in gh_call[0][0]
+ assert artifact_path in gh_call[0][0]
+ assert f"{artifact_path}.sha256" in gh_call[0][0]
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_default_version(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publish with default version."""
+ artifact_path = os.path.join(temp_repo_dir, "go-app_1.0_s390x")
+ with open(artifact_path, "w") as f:
+ f.write("fake binary")
+
+ mock_checksum.return_value = "abc123"
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = GoBinaryBuilder()
+ artifact = {} # No version
+ builder.publish(artifact_path, "go-app", artifact)
+
+ # Should use default version 1.0
+ gh_call = mock_run.call_args
+ assert "v1.0" in gh_call[0][0]
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_subprocess_error(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publish handles subprocess errors gracefully."""
+ artifact_path = os.path.join(temp_repo_dir, "go-app_1.0.0_s390x")
+ with open(artifact_path, "w") as f:
+ f.write("fake binary")
+
+ mock_checksum.return_value = "abc123"
+
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(
+ 1, 'gh', stderr=b'GitHub error'
+ ))
+
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.publish(artifact_path, "go-app", artifact)
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_working_directory(self, mock_checksum, temp_repo_dir, mocker):
+ """Test that publish runs in the correct working directory."""
+ artifact_path = os.path.join(temp_repo_dir, "build", "go-app_1.0.0_s390x")
+ os.makedirs(os.path.dirname(artifact_path), exist_ok=True)
+ with open(artifact_path, "w") as f:
+ f.write("fake binary")
+
+ mock_checksum.return_value = "xyz789"
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = GoBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "go-app", artifact)
+
+ # Verify cwd parameter
+ gh_call = mock_run.call_args
+ assert gh_call[1]['cwd'] == os.path.dirname(artifact_path)
diff --git a/tests/test_java_binary_builder.py b/tests/test_java_binary_builder.py
new file mode 100644
index 0000000..18e54b0
--- /dev/null
+++ b/tests/test_java_binary_builder.py
@@ -0,0 +1,271 @@
+import pytest
+import os
+from unittest.mock import MagicMock, call, patch
+from builders.binary.java_binary_builder import JavaBinaryBuilder, detect_build_system, BUILD_SYSTEMS
+
+
+class TestDetectBuildSystem:
+ """Test build system detection for Maven and Gradle."""
+
+ def test_detect_maven(self, temp_repo_dir):
+ """Test detection of Maven build system."""
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ system, config = detect_build_system(temp_repo_dir)
+
+ assert system == "maven"
+ assert config == BUILD_SYSTEMS["maven"]
+
+ def test_detect_gradle_groovy(self, temp_repo_dir):
+ """Test detection of Gradle (Groovy) build system."""
+ gradle_path = os.path.join(temp_repo_dir, "build.gradle")
+ with open(gradle_path, "w") as f:
+ f.write("plugins { id 'java' }")
+
+ system, config = detect_build_system(temp_repo_dir)
+
+ assert system == "gradle"
+ assert config == BUILD_SYSTEMS["gradle"]
+
+ def test_detect_gradle_kotlin(self, temp_repo_dir):
+ """Test detection of Gradle (Kotlin) build system."""
+ gradle_kts_path = os.path.join(temp_repo_dir, "build.gradle.kts")
+ with open(gradle_kts_path, "w") as f:
+ f.write("plugins { kotlin(\"jvm\") }")
+
+ system, config = detect_build_system(temp_repo_dir)
+
+ assert system == "gradle"
+ assert config == BUILD_SYSTEMS["gradle"]
+
+ def test_no_build_system_found(self, temp_repo_dir):
+ """Test when no build system is detected."""
+ system, config = detect_build_system(temp_repo_dir)
+
+ assert system is None
+ assert config is None
+
+
+class TestJavaBinaryBuilder:
+ """Test JavaBinaryBuilder build and publish methods."""
+
+ def test_build_maven_success(self, temp_repo_dir, mocker):
+ """Test successful Maven build."""
+ # Setup
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ target_dir = os.path.join(temp_repo_dir, "target")
+ os.makedirs(target_dir, exist_ok=True)
+
+ jar_file = os.path.join(target_dir, "app-1.0.0.jar")
+ with open(jar_file, "w") as f:
+ f.write("fake jar content")
+
+ # Mock subprocess.run
+ mock_run = mocker.patch('subprocess.run')
+
+ # Mock os.listdir to return JAR files
+ mocker.patch('os.listdir', return_value=["app-1.0.0.jar"])
+
+ # Build
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ result_path = builder.build(temp_repo_dir, "test-app", artifact)
+
+ # Assertions
+ assert result_path == os.path.join(temp_repo_dir, "build", "test-app_1.0.0_s390x.jar")
+ assert mock_run.call_count == 2 # docker run + cp
+
+ # Verify Docker command
+ docker_call = mock_run.call_args_list[0]
+ assert "docker" in docker_call[0][0]
+ assert "run" in docker_call[0][0]
+ assert "maven:3.9-eclipse-temurin-17" in docker_call[0][0]
+ assert "mvn" in docker_call[0][0]
+ assert "-DskipTests" in docker_call[0][0]
+
+ def test_build_gradle_success(self, temp_repo_dir, mocker):
+ """Test successful Gradle build."""
+ # Setup
+ gradle_path = os.path.join(temp_repo_dir, "build.gradle")
+ with open(gradle_path, "w") as f:
+ f.write("plugins { id 'java' }")
+
+ build_dir = os.path.join(temp_repo_dir, "build", "libs")
+ os.makedirs(build_dir, exist_ok=True)
+
+ jar_file = os.path.join(build_dir, "app-1.0.0.jar")
+ with open(jar_file, "w") as f:
+ f.write("fake jar content")
+
+ # Mock subprocess.run
+ mock_run = mocker.patch('subprocess.run')
+
+ # Mock os.listdir
+ mocker.patch('os.listdir', return_value=["app-1.0.0.jar"])
+
+ # Build
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "2.0.0"}
+ result_path = builder.build(temp_repo_dir, "gradle-app", artifact)
+
+ # Assertions
+ assert result_path == os.path.join(temp_repo_dir, "build", "gradle-app_2.0.0_s390x.jar")
+ assert mock_run.call_count == 2
+
+ # Verify Docker command uses Gradle
+ docker_call = mock_run.call_args_list[0]
+ assert "gradle:8.7-jdk17" in docker_call[0][0]
+ assert "gradle" in docker_call[0][0]
+
+ def test_build_custom_docker_image(self, temp_repo_dir, mocker):
+ """Test build with custom Docker image."""
+ # Setup
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ target_dir = os.path.join(temp_repo_dir, "target")
+ os.makedirs(target_dir, exist_ok=True)
+ jar_file = os.path.join(target_dir, "app.jar")
+ with open(jar_file, "w") as f:
+ f.write("fake jar")
+
+ mock_run = mocker.patch('subprocess.run')
+ mocker.patch('os.listdir', return_value=["app.jar"])
+
+ # Build with custom image
+ builder = JavaBinaryBuilder()
+ artifact = {
+ "version": "1.0.0",
+ "docker_image": "maven:3.9-eclipse-temurin-21"
+ }
+ builder.build(temp_repo_dir, "custom-app", artifact)
+
+ # Verify custom Docker image is used
+ docker_call = mock_run.call_args_list[0]
+ assert "maven:3.9-eclipse-temurin-21" in docker_call[0][0]
+
+ def test_build_no_build_system_found(self, temp_repo_dir):
+ """Test build fails when no build system is found."""
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(RuntimeError, match="No supported Java build file found"):
+ builder.build(temp_repo_dir, "no-build", artifact)
+
+ def test_build_no_jar_found(self, temp_repo_dir, mocker):
+ """Test build fails when no JAR file is produced."""
+ # Setup Maven project without JAR output
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ target_dir = os.path.join(temp_repo_dir, "target")
+ os.makedirs(target_dir, exist_ok=True)
+
+ mock_run = mocker.patch('subprocess.run')
+ mocker.patch('os.listdir', return_value=[]) # No JARs
+
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(RuntimeError, match="No runnable JAR found"):
+ builder.build(temp_repo_dir, "no-jar", artifact)
+
+ def test_build_filters_sources_and_javadoc_jars(self, temp_repo_dir, mocker):
+ """Test that sources and javadoc JARs are filtered out."""
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ target_dir = os.path.join(temp_repo_dir, "target")
+ os.makedirs(target_dir, exist_ok=True)
+
+ # Create multiple JARs
+ for jar_name in ["app-1.0.0.jar", "app-1.0.0-sources.jar", "app-1.0.0-javadoc.jar"]:
+ with open(os.path.join(target_dir, jar_name), "w") as f:
+ f.write("fake jar")
+
+ mock_run = mocker.patch('subprocess.run')
+ mocker.patch('os.listdir', return_value=[
+ "app-1.0.0.jar",
+ "app-1.0.0-sources.jar",
+ "app-1.0.0-javadoc.jar"
+ ])
+
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.build(temp_repo_dir, "multi-jar", artifact)
+
+ # Verify cp command uses main JAR, not sources/javadoc
+ cp_call = mock_run.call_args_list[1]
+ assert "app-1.0.0.jar" in cp_call[0][0][1]
+ assert "sources" not in cp_call[0][0][1]
+ assert "javadoc" not in cp_call[0][0][1]
+
+ def test_build_subprocess_error(self, temp_repo_dir, mocker):
+ """Test build handles subprocess errors gracefully."""
+ pom_path = os.path.join(temp_repo_dir, "pom.xml")
+ with open(pom_path, "w") as f:
+ f.write("")
+
+ # Mock subprocess to raise error
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(1, 'docker'))
+
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.build(temp_repo_dir, "error-app", artifact)
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_success(self, mock_checksum, temp_repo_dir, mocker):
+ """Test successful artifact publishing."""
+ # Setup
+ artifact_path = os.path.join(temp_repo_dir, "test-app_1.0.0_s390x.jar")
+ with open(artifact_path, "w") as f:
+ f.write("fake jar")
+
+ mock_checksum.return_value = "abc123"
+ mock_run = mocker.patch('subprocess.run')
+
+ # Publish
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "test-app", artifact)
+
+ # Assertions
+ mock_checksum.assert_called_once_with(artifact_path)
+ mock_run.assert_called_once()
+
+ # Verify gh release create command
+ gh_call = mock_run.call_args
+ assert "gh" in gh_call[0][0]
+ assert "release" in gh_call[0][0]
+ assert "create" in gh_call[0][0]
+ assert "v1.0.0" in gh_call[0][0]
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_subprocess_error(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publish handles errors gracefully."""
+ artifact_path = os.path.join(temp_repo_dir, "test-app_1.0.0_s390x.jar")
+ with open(artifact_path, "w") as f:
+ f.write("fake jar")
+
+ mock_checksum.return_value = "abc123"
+
+ # Mock subprocess to raise error
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(1, 'gh'))
+
+ builder = JavaBinaryBuilder()
+ artifact = {"version": "1.0.0"}
+
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.publish(artifact_path, "test-app", artifact)
diff --git a/tests/test_script_builder.py b/tests/test_script_builder.py
new file mode 100644
index 0000000..fc9523b
--- /dev/null
+++ b/tests/test_script_builder.py
@@ -0,0 +1,329 @@
+import pytest
+import os
+from unittest.mock import patch, MagicMock
+from builders.script.loz_script_builder import ScriptBuilder
+
+
+class TestScriptBuilder:
+ """Test ScriptBuilder build and publish methods."""
+
+ def test_build_success(self, temp_repo_dir, mocker):
+ """Test successful script-based build."""
+ # Setup script repository
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ script_path = os.path.join(temp_repo_dir, "build.sh")
+ with open(script_path, "w") as f:
+ f.write("#!/bin/bash\necho 'Building...'")
+
+ # Create expected output file
+ output_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ with open(output_path, "w") as f:
+ f.write("fake tarball")
+
+ # Mock subprocess.run
+ mock_run = mocker.patch('subprocess.run')
+
+ # Build
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "build.sh",
+ "args": "--platform s390x"
+ }
+ }
+ result_path = builder.build(temp_repo_dir, "test-app", artifact)
+
+ # Assertions
+ assert os.path.normpath(result_path) == os.path.normpath(output_path)
+ mock_run.assert_called_once()
+
+ # Verify Docker command
+ docker_call = mock_run.call_args
+ assert "docker" in docker_call[0][0]
+ assert "run" in docker_call[0][0]
+ assert "bash" in docker_call[0][0]
+
+ def test_build_with_docker_required(self, temp_repo_dir, mocker):
+ """Test build when Docker is required within container."""
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ script_path = os.path.join(temp_repo_dir, "build.sh")
+ with open(script_path, "w") as f:
+ f.write("#!/bin/bash\necho 'Building with Docker...'")
+
+ output_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ with open(output_path, "w") as f:
+ f.write("fake tarball")
+
+ # Set environment variables
+ mocker.patch.dict(os.environ, {
+ 'DOCKER_USERNAME': 'testuser',
+ 'DOCKER_PASSWORD': 'testpass',
+ 'GH_TOKEN': 'ghtoken',
+ 'GH_PUSH_USER': 'pushuser'
+ })
+
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "build.sh",
+ "docker_required": True
+ }
+ }
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ # Verify Docker socket is mounted
+ docker_call = mock_run.call_args
+ assert "/var/run/docker.sock:/var/run/docker.sock" in docker_call[0][0]
+ assert "-e" in docker_call[0][0]
+
+ def test_build_missing_script_path(self, temp_repo_dir):
+ """Test build fails when script path is not specified."""
+ builder = ScriptBuilder()
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts"
+ # Missing path
+ }
+ }
+
+ with pytest.raises(ValueError, match="build_script.path required"):
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ def test_build_script_repo_not_found(self, temp_repo_dir):
+ """Test build fails when script repository is not found."""
+ builder = ScriptBuilder()
+ # Don't set script_repo_paths
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "missing-repo",
+ "path": "build.sh"
+ }
+ }
+
+ with pytest.raises(ValueError, match="Script repository missing-repo not found"):
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ def test_build_script_file_not_found(self, temp_repo_dir):
+ """Test build fails when script file doesn't exist."""
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "nonexistent.sh"
+ }
+ }
+
+ with pytest.raises(FileNotFoundError, match="Script .* not found"):
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ def test_build_output_not_created(self, temp_repo_dir, mocker):
+ """Test build fails when expected output is not created."""
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ script_path = os.path.join(temp_repo_dir, "build.sh")
+ with open(script_path, "w") as f:
+ f.write("#!/bin/bash\necho 'Building...'")
+
+ # Don't create output file
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "build.sh"
+ }
+ }
+
+ with pytest.raises(FileNotFoundError, match="Expected output .* not found"):
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ def test_build_subprocess_error(self, temp_repo_dir, mocker):
+ """Test build handles subprocess errors gracefully."""
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ script_path = os.path.join(temp_repo_dir, "build.sh")
+ with open(script_path, "w") as f:
+ f.write("#!/bin/bash\nexit 1")
+
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(
+ 1, 'bash', stderr=b'Script error'
+ ))
+
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "build.sh"
+ }
+ }
+
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ def test_build_custom_docker_image(self, temp_repo_dir, mocker):
+ """Test build with custom Docker image."""
+ script_repo_path = os.path.join(temp_repo_dir, "scripts")
+ os.makedirs(script_repo_path, exist_ok=True)
+
+ script_path = os.path.join(temp_repo_dir, "build.sh")
+ with open(script_path, "w") as f:
+ f.write("#!/bin/bash\necho 'Building...'")
+
+ output_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ with open(output_path, "w") as f:
+ f.write("fake tarball")
+
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ builder.set_script_repo_paths({"linux-on-ibm-z-scripts": script_repo_path})
+
+ artifact = {
+ "version": "1.0.0",
+ "build_script": {
+ "repo_name": "linux-on-ibm-z-scripts",
+ "path": "build.sh",
+ "docker_image": "alpine:latest"
+ }
+ }
+ builder.build(temp_repo_dir, "test-app", artifact)
+
+ # Verify custom Docker image
+ docker_call = mock_run.call_args
+ assert "alpine:latest" in docker_call[0][0]
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_success(self, mock_checksum, temp_repo_dir, mocker):
+ """Test successful artifact publishing."""
+ artifact_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ with open(artifact_path, "w") as f:
+ f.write("fake tarball")
+
+ mock_checksum.return_value = "abc123"
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "test-app", artifact)
+
+ # Assertions
+ mock_checksum.assert_called_once_with(artifact_path)
+ mock_run.assert_called_once()
+
+ # Verify gh release create command
+ gh_call = mock_run.call_args
+ assert "gh" in gh_call[0][0]
+ assert "release" in gh_call[0][0]
+ assert "create" in gh_call[0][0]
+ assert "v1.0.0" in gh_call[0][0]
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_with_rpm(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publishing with additional RPM artifact."""
+ artifact_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ rpm_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.rpm")
+
+ with open(artifact_path, "w") as f:
+ f.write("fake tarball")
+ with open(rpm_path, "w") as f:
+ f.write("fake rpm")
+
+ mock_checksum.return_value = "abc123"
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "test-app", artifact)
+
+ # Should call gh release twice: create + upload
+ assert mock_run.call_count == 2
+
+ # Verify upload call
+ upload_call = mock_run.call_args_list[1]
+ assert "upload" in upload_call[0][0]
+ # Normalize paths for cross-platform comparison
+ uploaded_file = os.path.normpath(upload_call[0][0][-1])
+ assert os.path.normpath(rpm_path) == uploaded_file
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_with_deb(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publishing with additional DEB artifact."""
+ artifact_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ deb_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.deb")
+
+ with open(artifact_path, "w") as f:
+ f.write("fake tarball")
+ with open(deb_path, "w") as f:
+ f.write("fake deb")
+
+ mock_checksum.return_value = "abc123"
+ mock_run = mocker.patch('subprocess.run')
+
+ builder = ScriptBuilder()
+ artifact = {"version": "1.0.0"}
+ builder.publish(artifact_path, "test-app", artifact)
+
+ # Should call gh release twice: create + upload
+ assert mock_run.call_count == 2
+
+ # Verify upload call
+ upload_call = mock_run.call_args_list[1]
+ assert "upload" in upload_call[0][0]
+ # Normalize paths for cross-platform comparison
+ uploaded_file = os.path.normpath(upload_call[0][0][-1])
+ assert os.path.normpath(deb_path) == uploaded_file
+
+ @patch('lib.checksum.generate_checksum')
+ def test_publish_subprocess_error(self, mock_checksum, temp_repo_dir, mocker):
+ """Test publish handles subprocess errors gracefully."""
+ artifact_path = os.path.join(temp_repo_dir, "test-app-1.0.0-linux-s390x.tar.gz")
+ with open(artifact_path, "w") as f:
+ f.write("fake tarball")
+
+ mock_checksum.return_value = "abc123"
+
+ import subprocess
+ mock_run = mocker.patch('subprocess.run', side_effect=subprocess.CalledProcessError(
+ 1, 'gh', stderr=b'GitHub error'
+ ))
+
+ builder = ScriptBuilder()
+ artifact = {"version": "1.0.0"}
+
+ # Note: The actual code doesn't have error handling for publish in ScriptBuilder
+ # This test documents that behavior
+ with pytest.raises(subprocess.CalledProcessError):
+ builder.publish(artifact_path, "test-app", artifact)