Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions .github/workflows/ci-cd-java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,13 +43,44 @@ jobs:
clean: 'true'
fetch-depth: 2

- 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:
distribution: 'temurin'
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: |
Expand Down Expand Up @@ -104,8 +129,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: |
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
.idea
/AGENTS.md
scripts/__pycache__
AGENTS.md
CLAUDE.md
.claude
200 changes: 200 additions & 0 deletions scripts/validate_java_version_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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 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 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}.')

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()