diff --git a/.github/workflows/ci-cd-java.yml b/.github/workflows/ci-cd-java.yml index 7eade61..007217c 100644 --- a/.github/workflows/ci-cd-java.yml +++ b/.github/workflows/ci-cd-java.yml @@ -12,12 +12,6 @@ on: required: false type: boolean default: false - jarArtifactName: - required: false - type: string - jarArtifactPath: - required: false - type: string performRelease: required: false type: boolean @@ -32,11 +26,10 @@ on: runTestsInsideDocker: required: false type: boolean - default: false + default: true env: IMAGE_NAME_MIXED_CASE: "${{ github.repository }}" - TEST_STAGE: test jobs: build-check-test-push: @@ -49,6 +42,31 @@ jobs: clean: 'true' fetch-depth: 2 + # Required since custom scripts from /scripts are being used + - name: Resolve shared workflow ref + id: resolve_shared_workflow_ref + run: | + set -euo pipefail + SHARED_WORKFLOW_REF=$(grep -roh \ + 'transitdata-shared-workflows/.github/workflows/[^@]*@[^ "'\'']*' \ + "${GITHUB_WORKSPACE}/.github/workflows/" 2>/dev/null \ + | sed 's/.*@//' | head -1 || true) + + if [[ -z "${SHARED_WORKFLOW_REF}" ]]; then + echo "::warning::Could not detect shared workflow ref from caller workflows; falling back to main" + SHARED_WORKFLOW_REF="main" + fi + + echo "Resolved shared workflow ref: ${SHARED_WORKFLOW_REF}" + echo "shared_workflow_ref=${SHARED_WORKFLOW_REF}" >> "$GITHUB_OUTPUT" + + - name: Checkout shared workflow scripts + uses: actions/checkout@v4 + with: + repository: HSLdevcom/transitdata-shared-workflows + ref: ${{ steps.resolve_shared_workflow_ref.outputs.shared_workflow_ref }} + path: .shared-workflows + - name: Setup JDK uses: actions/setup-java@v4 with: @@ -56,12 +74,48 @@ jobs: java-version: '25' cache: 'maven' + - name: Validate Java version consistency + working-directory: ${{ inputs.workingDirectory }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JAVA_TOOL_OPTIONS: "" + MAVEN_OPTS: "" + run: python3 "${GITHUB_WORKSPACE}/.shared-workflows/scripts/validate_java_version_consistency.py" + - name: Check code format and lint working-directory: ${{ inputs.workingDirectory }} run: | mvn spotless:check - - name: Run tests outside Docker + - name: Run unit tests inside Docker + if: ${{ inputs.runTestsInsideDocker }} + working-directory: ${{ inputs.workingDirectory }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + DOCKER_BUILDKIT: "1" + run: | + cat > /tmp/Dockerfile.test << DOCKERFILE + # syntax=docker/dockerfile:1 + # check=error=true + FROM ${TEST_BASE_IMAGE} + WORKDIR /usr/app + COPY . . + COPY .mvn/settings.xml /root/.m2/settings.xml + RUN --mount=type=secret,id=github_token \ + --mount=type=secret,id=github_actor \ + export GITHUB_TOKEN="\$(cat /run/secrets/github_token)" && \ + export GITHUB_ACTOR="\$(cat /run/secrets/github_actor)" && \ + ./mvnw -B test + DOCKERFILE + docker build \ + --secret id=github_token,env=GITHUB_TOKEN \ + --secret id=github_actor,env=GITHUB_ACTOR \ + -f /tmp/Dockerfile.test \ + . + + - name: Run unit tests outside Docker + if: ${{ !inputs.runTestsInsideDocker }} working-directory: ${{ inputs.workingDirectory }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -104,8 +158,8 @@ jobs: if: ${{ inputs.uploadJarArtifact }} uses: actions/upload-artifact@v4 with: - name: ${{ inputs.jarArtifactName }} - path: ${{ inputs.jarArtifactPath }} + name: 'app.jar' + path: '/app/app.jar' - name: Set Docker Image Name run: | @@ -119,19 +173,6 @@ jobs: echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITHUB_ENV" - - name: Build & run tests inside Docker - if: ${{ inputs.runTestsInsideDocker }} - uses: docker/build-push-action@v6 - with: - context: ${{ inputs.workingDirectory }} - load: true - target: "${{ env.TEST_STAGE }}" - tags: "${{ env.IMAGE_NAME }}:${{ env.TEST_STAGE }}" - secrets: - github_token=${{ secrets.GITHUB_TOKEN }} - build-args: - GITHUB_ACTOR=${{ github.actor }} - - name: Build Docker Image uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/ci-cd-kotlin.yml b/.github/workflows/ci-cd-kotlin.yml index da3bda8..bdfa3fe 100644 --- a/.github/workflows/ci-cd-kotlin.yml +++ b/.github/workflows/ci-cd-kotlin.yml @@ -21,6 +21,14 @@ on: required: false type: boolean default: false + runTestsInsideDocker: + required: false + type: boolean + default: true + hasIntegrationTests: + required: false + type: boolean + default: false env: IMAGE_NAME_MIXED_CASE: "${{ github.repository }}" @@ -36,6 +44,30 @@ jobs: clean: 'true' fetch-depth: 2 + # Required since custom scripts from /scripts are being used + - name: Resolve shared workflow ref + run: | + set -euo pipefail + SHARED_WORKFLOW_REF=$(grep -roh \ + 'transitdata-shared-workflows/.github/workflows/[^@]*@[^ "'\'']*' \ + "${GITHUB_WORKSPACE}/.github/workflows/" 2>/dev/null \ + | sed 's/.*@//' | head -1 || true) + + if [[ -z "${SHARED_WORKFLOW_REF}" ]]; then + echo "::warning::Could not detect shared workflow ref from caller workflows; falling back to main" + SHARED_WORKFLOW_REF="main" + fi + + echo "Resolved shared workflow ref: ${SHARED_WORKFLOW_REF}" + echo "SHARED_WORKFLOW_REF=${SHARED_WORKFLOW_REF}" >> "$GITHUB_ENV" + + - name: Checkout shared workflow scripts + uses: actions/checkout@v4 + with: + repository: HSLdevcom/transitdata-shared-workflows + ref: ${{ env.SHARED_WORKFLOW_REF }} + path: .shared-workflows + - name: Setup JDK uses: actions/setup-java@v4 with: @@ -43,18 +75,55 @@ jobs: java-version: '11' cache: 'gradle' + - name: Validate Java version consistency + env: + JAVA_TOOL_OPTIONS: "" + run: python3 "${GITHUB_WORKSPACE}/.shared-workflows/scripts/validate_java_version_consistency.py" + - name: Check code format and lint run: ./gradlew spotlessCheck env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run tests + - name: Run unit tests inside Docker + if: ${{ inputs.runTestsInsideDocker }} env: - GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR_ARG: ${{ github.actor }} + DOCKER_BUILDKIT: "1" run: | - ./gradlew test jacocoTestReport --stacktrace + cat > /tmp/Dockerfile.test << DOCKERFILE + # syntax=docker/dockerfile:1 + # check=error=true + FROM ${TEST_BASE_IMAGE} + WORKDIR /usr/app + ARG GITHUB_ACTOR=github-actions + COPY . . + RUN --mount=type=secret,id=github_token \ + export GITHUB_TOKEN="\$(cat /run/secrets/github_token)" && \ + export GITHUB_ACTOR="\$GITHUB_ACTOR" && \ + ./gradlew test --stacktrace --no-daemon + DOCKERFILE + docker build \ + --secret id=github_token,env=GITHUB_TOKEN \ + --build-arg "GITHUB_ACTOR=${GITHUB_ACTOR_ARG}" \ + -f /tmp/Dockerfile.test \ + . + + - name: Run unit tests + if: ${{ inputs.hasIntegrationTests == false }} + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew test jacocoTestReport --stacktrace + + - name: Run unit tests and integration tests + if: ${{ inputs.hasIntegrationTests }} + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew test integrationTest jacocoTestReport --stacktrace - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3da524 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci.yml + +permissions: + contents: read + +on: + pull_request: + push: + branches: + - main + +jobs: + test-scripts: + name: Test Python scripts + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install pytest + run: pip install pytest + + - name: Run script tests + run: pytest scripts/ -v diff --git a/.gitignore b/.gitignore index b79d2d6..fa2e2d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .idea -/AGENTS.md +scripts/__pycache__ +AGENTS.md +CLAUDE.md +.claude \ No newline at end of file diff --git a/scripts/test_validate_java_version_consistency.py b/scripts/test_validate_java_version_consistency.py new file mode 100644 index 0000000..e6ee0b6 --- /dev/null +++ b/scripts/test_validate_java_version_consistency.py @@ -0,0 +1,338 @@ +import pytest + +from validate_java_version_consistency import ( + extract_java_version_from_docker_tag, + normalize_java_version, + parse_docker_java_version, + parse_docker_jdk_image, + parse_gradle_java_version, + resolve_args, +) + + +# --------------------------------------------------------------------------- +# normalize_java_version +# --------------------------------------------------------------------------- + +class TestNormalizeJavaVersion: + def test_plain_number(self): + assert normalize_java_version("25") == "25" + + def test_strips_whitespace(self): + assert normalize_java_version(" 17 ") == "17" + + def test_strips_single_quotes(self): + assert normalize_java_version("'21'") == "21" + + def test_strips_double_quotes(self): + assert normalize_java_version('"11"') == "11" + + def test_legacy_1x_format_java_8(self): + assert normalize_java_version("1.8") == "8" + + def test_legacy_1x_format_java_11(self): + assert normalize_java_version("1.11") == "11" + + def test_gradle_java_version_constant(self): + assert normalize_java_version("JavaVersion.VERSION_25") == "25" + + def test_gradle_java_version_constant_11(self): + assert normalize_java_version("JavaVersion.VERSION_11") == "11" + + def test_leading_zeros_stripped(self): + # int() conversion removes leading zeros + assert normalize_java_version("011") == "11" + + # --- sad paths --- + + def test_none_input(self): + assert normalize_java_version(None) is None + + def test_empty_string(self): + assert normalize_java_version("") is None + + def test_only_whitespace(self): + assert normalize_java_version(" ") is None + + def test_unexpanded_placeholder(self): + assert normalize_java_version("${java.version}") is None + + def test_no_digits(self): + assert normalize_java_version("abc") is None + + +# --------------------------------------------------------------------------- +# extract_java_version_from_docker_tag +# --------------------------------------------------------------------------- + +class TestExtractJavaVersionFromDockerTag: + def test_plain_number(self): + assert extract_java_version_from_docker_tag("25") == "25" + + def test_hsl_base_image_jre_tag(self): + assert extract_java_version_from_docker_tag("1.0.2-25-java-jre") == "25" + + def test_hsl_base_image_jdk_tag(self): + assert extract_java_version_from_docker_tag("1.0.2-25-java-jdk") == "25" + + def test_version_dash_jdk(self): + assert extract_java_version_from_docker_tag("25-jdk") == "25" + + def test_version_dash_jre(self): + assert extract_java_version_from_docker_tag("25-jre") == "25" + + def test_java_dash_version(self): + assert extract_java_version_from_docker_tag("java-11") == "11" + + def test_case_insensitive_jdk(self): + assert extract_java_version_from_docker_tag("17-JDK") == "17" + + # --- sad paths --- + + def test_latest_tag(self): + assert extract_java_version_from_docker_tag("latest") is None + + def test_empty_tag(self): + assert extract_java_version_from_docker_tag("") is None + + def test_no_java_version_hint(self): + assert extract_java_version_from_docker_tag("ubuntu") is None + + +# --------------------------------------------------------------------------- +# resolve_args +# --------------------------------------------------------------------------- + +class TestResolveArgs: + def test_curly_brace_syntax(self): + assert resolve_args("${BASE}", {"BASE": "ubuntu:22.04"}) == "ubuntu:22.04" + + def test_dollar_word_syntax(self): + assert resolve_args("$VERSION", {"VERSION": "1.0"}) == "1.0" + + def test_embedded_variable(self): + assert resolve_args("prefix-${TAG}-suffix", {"TAG": "foo"}) == "prefix-foo-suffix" + + def test_multiple_variables(self): + assert resolve_args("${A}/${B}", {"A": "x", "B": "y"}) == "x/y" + + def test_no_variables(self): + assert resolve_args("plain-string", {}) == "plain-string" + + # --- sad paths --- + + def test_missing_curly_brace_variable_left_intact(self): + assert resolve_args("${MISSING}", {}) == "${MISSING}" + + def test_missing_dollar_word_variable_left_intact(self): + assert resolve_args("$MISSING", {}) == "$MISSING" + + def test_empty_string(self): + assert resolve_args("", {}) == "" + + +# --------------------------------------------------------------------------- +# parse_docker_java_version (uses tmp_path fixture) +# --------------------------------------------------------------------------- + +class TestParseDockerJavaVersion: + def test_simple_jre_tag(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jre\n" + ) + version, image_ref = parse_docker_java_version(str(dockerfile)) + assert version == "25" + assert "1.0.2-25-java-jre" in image_ref + + def test_multistage_uses_last_non_scratch(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk AS build\n" + "FROM scratch\n" + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jre\n" + ) + version, image_ref = parse_docker_java_version(str(dockerfile)) + assert version == "25" + assert "java-jre" in image_ref + + def test_scratch_is_skipped_when_real_image_exists(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-17-java-jre AS base\n" + "FROM scratch\n" + ) + # scratch is not the last non-scratch image; base is + version, _ = parse_docker_java_version(str(dockerfile)) + assert version == "17" + + def test_comments_are_ignored(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "# syntax=docker/dockerfile:1\n" + "# This is a comment\n" + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-11-java-jre\n" + ) + version, _ = parse_docker_java_version(str(dockerfile)) + assert version == "11" + + def test_image_with_digest_strips_digest(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jre@sha256:abc123\n" + ) + version, _ = parse_docker_java_version(str(dockerfile)) + assert version == "25" + + # --- sad paths --- + + def test_no_from_instruction_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("RUN echo hello\n") + with pytest.raises(RuntimeError, match="Could not find a runtime image"): + parse_docker_java_version(str(dockerfile)) + + def test_only_scratch_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM scratch\n") + with pytest.raises(RuntimeError, match="Could not find a runtime image"): + parse_docker_java_version(str(dockerfile)) + + def test_image_without_tag_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM ubuntu\n") + with pytest.raises(RuntimeError, match="Could not determine the Java tag"): + parse_docker_java_version(str(dockerfile)) + + def test_unrecognizable_java_version_in_tag_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM ubuntu:latest\n") + with pytest.raises(RuntimeError, match="Could not determine the Java version"): + parse_docker_java_version(str(dockerfile)) + + +# --------------------------------------------------------------------------- +# parse_docker_jdk_image (uses tmp_path fixture) +# --------------------------------------------------------------------------- + +class TestParseDockerJdkImage: + def test_standard_multistage_returns_first_real_from(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk AS base\n" + "FROM base AS test\n" + "FROM base AS build\n" + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jre\n" + ) + image = parse_docker_jdk_image(str(dockerfile)) + assert image == "hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk" + + def test_single_stage_returns_only_image(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk\n" + ) + image = parse_docker_jdk_image(str(dockerfile)) + assert image == "hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk" + + def test_skips_scratch(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM scratch\n" + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk AS base\n" + ) + image = parse_docker_jdk_image(str(dockerfile)) + assert "java-jdk" in image + + def test_skips_stage_aliases(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk AS base\n" + "FROM base AS test\n" + ) + image = parse_docker_jdk_image(str(dockerfile)) + assert image == "hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk" + + def test_comments_are_ignored(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "# syntax=docker/dockerfile:1\n" + "# check=error=true\n" + "FROM hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk AS base\n" + ) + image = parse_docker_jdk_image(str(dockerfile)) + assert "java-jdk" in image + + def test_no_real_from_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM scratch\n") + with pytest.raises(RuntimeError, match="Could not find a JDK base image"): + parse_docker_jdk_image(str(dockerfile)) + + def test_only_aliases_raises(self, tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text( + "FROM base AS test\n" + "FROM build AS final\n" + ) + with pytest.raises(RuntimeError, match="Could not find a JDK base image"): + parse_docker_jdk_image(str(dockerfile)) + + +# --------------------------------------------------------------------------- +# parse_gradle_java_version (uses tmp_path fixture) +# --------------------------------------------------------------------------- + +class TestParseGradleJavaVersion: + def test_jvm_target_string(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text('compileKotlin { kotlinOptions { jvmTarget = "25" } }\n') + version, source = parse_gradle_java_version(str(gradle)) + assert version == "25" + assert "25" in source + + def test_jvm_target_java_version_constant(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text("tasks.withType { jvmTarget = JavaVersion.VERSION_17 }\n") + version, _ = parse_gradle_java_version(str(gradle)) + assert version == "17" + + def test_language_version_set(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text( + "java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }\n" + ) + version, _ = parse_gradle_java_version(str(gradle)) + assert version == "21" + + def test_source_compatibility(self, tmp_path): + gradle = tmp_path / "build.gradle" + gradle.write_text("sourceCompatibility = JavaVersion.VERSION_11\n") + version, _ = parse_gradle_java_version(str(gradle)) + assert version == "11" + + def test_target_compatibility(self, tmp_path): + gradle = tmp_path / "build.gradle" + gradle.write_text("targetCompatibility = JavaVersion.VERSION_11\n") + version, _ = parse_gradle_java_version(str(gradle)) + assert version == "11" + + def test_source_name_in_reported_source(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text('compileKotlin { kotlinOptions { jvmTarget = "25" } }\n') + _, source = parse_gradle_java_version(str(gradle)) + assert "build.gradle.kts" in source + + # --- sad paths --- + + def test_no_matching_pattern_raises(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text("plugins { kotlin(\"jvm\") }\n") + with pytest.raises(RuntimeError, match="Could not determine the Java version"): + parse_gradle_java_version(str(gradle)) + + def test_empty_file_raises(self, tmp_path): + gradle = tmp_path / "build.gradle.kts" + gradle.write_text("") + with pytest.raises(RuntimeError, match="Could not determine the Java version"): + parse_gradle_java_version(str(gradle)) diff --git a/scripts/validate_java_version_consistency.py b/scripts/validate_java_version_consistency.py new file mode 100644 index 0000000..348de9b --- /dev/null +++ b/scripts/validate_java_version_consistency.py @@ -0,0 +1,241 @@ +import os +import re +import subprocess +import sys + + +def normalize_java_version(value): + if not value: + return None + + cleaned = value.strip().strip('"\'') + if not cleaned or cleaned.startswith('${'): + return None + + cleaned = cleaned.replace('JavaVersion.VERSION_', '') + + if cleaned.startswith('1.'): + parts = cleaned.split('.') + if len(parts) > 1 and parts[1].isdigit(): + return parts[1] + + match = re.search(r'(\d+)', cleaned) + if match: + return str(int(match.group(1))) + + return None + + +def extract_java_version_from_docker_tag(tag): + patterns = ( + r'(?:^|[._-])(\d+)(?:[._-])java(?:[._-])(jre|jdk)(?:$|[._-])', + r'(?:^|[._-])java(?:[._-])(\d+)(?:$|[._-])', + r'(?:^|[._-])(\d+)(?:[._-])(jre|jdk)(?:$|[._-])', + r'^(\d+)$', + ) + + for pattern in patterns: + match = re.search(pattern, tag, re.IGNORECASE) + if match: + return normalize_java_version(match.group(1)) + + return None + + +def resolve_args(value, args): + def replace(match): + variable_name = match.group(1) or match.group(2) + return args.get(variable_name, match.group(0)) + + return re.sub(r'\$\{([^}]+)\}|\$(\w+)', replace, value) + + +def _is_stage_alias(image): + """Return True if the image string is a multi-stage alias (e.g. 'base', 'build'), not a registry reference.""" + return ':' not in image and '/' not in image + + +def parse_docker_jdk_image(dockerfile_path): + """Return the image ref of the first real (non-scratch, non-alias) FROM stage. + + In the standard 4-stage Java Dockerfile pattern the first real FROM is the JDK + build/test image, e.g. hsldevcom/infodevops-docker-base-images:1.0.2-25-java-jdk. + """ + args = {} + + with open(dockerfile_path, encoding='utf-8') as dockerfile: + for raw_line in dockerfile: + line = raw_line.split('#', 1)[0].strip() + if not line: + continue + + from_match = re.match(r'^FROM\s+([^\s]+)', line, re.IGNORECASE) + if from_match: + image = resolve_args(from_match.group(1).strip(), args) + if image.lower() != 'scratch' and not _is_stage_alias(image): + return image + + raise RuntimeError('Could not find a JDK base image in Dockerfile.') + + +def parse_docker_java_version(dockerfile_path): + args = {} + images = [] + + with open(dockerfile_path, encoding='utf-8') as dockerfile: + for raw_line in dockerfile: + line = raw_line.split('#', 1)[0].strip() + if not line: + continue + + arg_match = re.match(r'^ARG\s+([A-Za-z_][A-Za-z0-9_]*)=(.+)$', line, re.IGNORECASE) + if arg_match: + args[arg_match.group(1)] = arg_match.group(2).strip() + continue + + from_match = re.match(r'^FROM\s+([^\s]+)', line, re.IGNORECASE) + if from_match: + image = resolve_args(from_match.group(1).strip(), args) + images.append(image) + + runtime_images = [image for image in images if image.lower() != 'scratch'] + if not runtime_images: + raise RuntimeError('Could not find a runtime image in Dockerfile.') + + image_ref = runtime_images[-1].split('@', 1)[0] + last_slash = image_ref.rfind('/') + last_colon = image_ref.rfind(':') + + if last_colon <= last_slash: + raise RuntimeError(f'Could not determine the Java tag from Docker image "{image_ref}".') + + tag = image_ref[last_colon + 1 :] + java_version = extract_java_version_from_docker_tag(tag) + if not java_version: + raise RuntimeError( + 'Could not determine the Java version from Docker tag ' + f'"{tag}". Expected a tag like "25", "25-jdk", or "1.0.2-25-java-jre".' + ) + + return java_version, image_ref + + +def evaluate_maven_property(expression): + result = subprocess.run( + [ + 'mvn', + '-q', + '-DforceStdout', + '-Dstyle.color=never', + 'help:evaluate', + f'-Dexpression={expression}', + ], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + return None + + lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] + lines = [line for line in lines if not line.startswith('[')] + return lines[-1] if lines else None + + +def parse_maven_java_version(): + for expression in ( + 'maven.compiler.release', + 'maven.compiler.target', + 'java.version', + ): + value = evaluate_maven_property(expression) + normalized = normalize_java_version(value) + if normalized: + return normalized, f'pom.xml ({expression}={value})' + + raise RuntimeError( + 'Could not determine the Java version from pom.xml. ' + 'Expected maven.compiler.release, maven.compiler.target, or java.version.' + ) + + +def parse_gradle_java_version(gradle_path): + with open(gradle_path, encoding='utf-8') as gradle_file: + content = gradle_file.read() + + patterns = [ + r'jvmTarget\s*=\s*["\']([^"\']+)["\']', + r'jvmTarget\s*=\s*JavaVersion\.VERSION_?(\d+)', + r'languageVersion(?:\.set)?\s*\(?\s*JavaLanguageVersion\.of\((\d+)\)\s*\)?', + r'sourceCompatibility\s*=\s*JavaVersion\.VERSION_?(\d+)', + r'targetCompatibility\s*=\s*JavaVersion\.VERSION_?(\d+)', + ] + + for pattern in patterns: + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + value = match.group(1) + normalized = normalize_java_version(value) + if normalized: + return normalized, f'{os.path.basename(gradle_path)} ({value})' + + raise RuntimeError( + 'Could not determine the Java version from the Gradle build file. ' + 'Expected compileKotlin.kotlinOptions.jvmTarget or java.toolchain.languageVersion.' + ) + + +def _write_github_file(env_var_name, content): + """Append key=value to a GitHub Actions environment file if the path is set.""" + path = os.getenv(env_var_name) + if path: + with open(path, 'a') as f: + f.write(f'{content}\n') + + +def main(): + dockerfile_path = os.path.join(os.getcwd(), 'Dockerfile') + if not os.path.exists(dockerfile_path): + raise RuntimeError(f'Dockerfile not found at {dockerfile_path}.') + + jdk_image = parse_docker_jdk_image(dockerfile_path) + print(f'JDK base image: {jdk_image}') + _write_github_file('GITHUB_OUTPUT', f'TEST_BASE_IMAGE={jdk_image}') + _write_github_file('GITHUB_ENV', f'TEST_BASE_IMAGE={jdk_image}') + + docker_version, docker_image = parse_docker_java_version(dockerfile_path) + + pom_path = os.path.join(os.getcwd(), 'pom.xml') + gradle_paths = [ + os.path.join(os.getcwd(), 'build.gradle.kts'), + os.path.join(os.getcwd(), 'build.gradle'), + ] + + if os.path.exists(pom_path): + build_version, build_source = parse_maven_java_version() + else: + existing_gradle_paths = [path for path in gradle_paths if os.path.exists(path)] + if not existing_gradle_paths: + raise RuntimeError( + 'Could not find pom.xml, build.gradle.kts, or build.gradle in the working directory.' + ) + build_version, build_source = parse_gradle_java_version(existing_gradle_paths[0]) + + if docker_version != build_version: + print( + 'Java version mismatch: ' + f'Dockerfile uses Java {docker_version} ({docker_image}), ' + f'but {build_source} resolves to Java {build_version}.', + file=sys.stderr, + ) + sys.exit(1) + + print( + 'Java version check passed: ' + f'Dockerfile uses Java {docker_version} and {build_source} matches.' + ) + + +if __name__ == '__main__': + main()