# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - contents: read # Required for reading version files --- name: Version File Parser description: 'Universal parser for common version detection files (.tool-versions, Dockerfile, devcontainer.json, etc.)' author: 'Ismo Vuorinen' branding: icon: search color: gray-dark inputs: language: description: 'Programming language name (node, python, php, go, dotnet)' required: true tool-versions-key: description: 'Key name in .tool-versions file (nodejs, python, php, golang, dotnet)' required: true dockerfile-image: description: 'Docker image name pattern (node, python, php, golang, dotnet)' required: true version-file: description: 'Language-specific version file (.nvmrc, .python-version, etc.)' required: false validation-regex: description: 'Version validation regex pattern' required: false default: '^[0-9]+\.[0-9]+(\.[0-9]+)?$' default-version: description: 'Default version to use if no version is detected' required: false outputs: tool-versions-version: description: 'Version found in .tool-versions' value: ${{ steps.parse.outputs.tool-versions-version }} dockerfile-version: description: 'Version found in Dockerfile' value: ${{ steps.parse.outputs.dockerfile-version }} devcontainer-version: description: 'Version found in devcontainer.json' value: ${{ steps.parse.outputs.devcontainer-version }} version-file-version: description: 'Version found in language-specific version file' value: ${{ steps.parse.outputs.version-file-version }} config-file-version: description: 'Version found in language config files (package.json, composer.json, etc.)' value: ${{ steps.parse.outputs.config-file-version }} detected-version: description: 'Final detected version (first found or default)' value: ${{ steps.parse.outputs.detected-version }} package-manager: description: 'Detected package manager (npm, yarn, pnpm, composer, pip, poetry, etc.)' value: ${{ steps.parse.outputs.package-manager }} runs: using: composite steps: - name: Parse Version Files id: parse shell: bash env: VALIDATION_REGEX: ${{ inputs.validation-regex }} LANGUAGE: ${{ inputs.language }} TOOL_VERSIONS_KEY: ${{ inputs.tool-versions-key }} DOCKERFILE_IMAGE: ${{ inputs.dockerfile-image }} VERSION_FILE: ${{ inputs.version-file }} DEFAULT_VERSION: ${{ inputs.default-version }} run: |- set -euo pipefail # Function to validate version format validate_version() { local version=$1 local regex="$VALIDATION_REGEX" # Test regex validity if ! bash -c "[[ 'test' =~ \${regex} ]]" 2>/dev/null; then echo "::error::Invalid validation regex pattern: $regex" >&2 exit 1 fi # Validate version using safe regex matching with quoted variable if [[ $version =~ ^${regex}$ ]]; then return 0 fi return 1 } # Function to clean version string clean_version() { echo "$1" | sed 's/^[vV]//' | tr -d ' \n\r' } # Initialize outputs echo "tool-versions-version=" >> $GITHUB_OUTPUT echo "dockerfile-version=" >> $GITHUB_OUTPUT echo "devcontainer-version=" >> $GITHUB_OUTPUT echo "version-file-version=" >> $GITHUB_OUTPUT echo "config-file-version=" >> $GITHUB_OUTPUT echo "detected-version=" >> $GITHUB_OUTPUT echo "package-manager=" >> $GITHUB_OUTPUT # Language detection patterns language="$LANGUAGE" # Parse .tool-versions file if [ -f .tool-versions ]; then echo "Checking .tool-versions for $TOOL_VERSIONS_KEY..." >&2 version=$(awk "/^$TOOL_VERSIONS_KEY[[:space:]]/ {gsub(/#.*/, \"\"); print \$2; exit}" .tool-versions 2>/dev/null || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found $LANGUAGE version in .tool-versions: $version" >&2 echo "tool-versions-version=$version" >> $GITHUB_OUTPUT fi fi fi # Parse Dockerfile if [ -f Dockerfile ]; then echo "Checking Dockerfile for $DOCKERFILE_IMAGE..." >&2 version=$(grep -iF "FROM" Dockerfile | grep -F "$DOCKERFILE_IMAGE:" | head -1 | \ sed -n "s/.*$DOCKERFILE_IMAGE:\([0-9]\+\(\.[0-9]\+\)*\)\(-[^:]*\)\?.*/\1/p" || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found $LANGUAGE version in Dockerfile: $version" >&2 echo "dockerfile-version=$version" >> $GITHUB_OUTPUT fi fi fi # Parse devcontainer.json if [ -f .devcontainer/devcontainer.json ]; then echo "Checking devcontainer.json for $DOCKERFILE_IMAGE..." >&2 if command -v jq >/dev/null 2>&1; then version=$(jq -r '.image // empty' .devcontainer/devcontainer.json 2>/dev/null | sed -n "s/.*$DOCKERFILE_IMAGE:\([0-9]\+\(\.[0-9]\+\)*\)\(-[^:]*\)\?.*/\1/p" || echo "") if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found $LANGUAGE version in devcontainer: $version" >&2 echo "devcontainer-version=$version" >> $GITHUB_OUTPUT fi fi else echo "jq not available, skipping devcontainer parsing" >&2 fi fi # Parse language-specific version file if [ -n "$VERSION_FILE" ] && [ -f "$VERSION_FILE" ]; then echo "Checking $VERSION_FILE..." >&2 version=$(tr -d '\r' < "$VERSION_FILE" | head -1) if [ -n "$version" ]; then version=$(clean_version "$version") if validate_version "$version"; then echo "Found $LANGUAGE version in $VERSION_FILE: $version" >&2 echo "version-file-version=$version" >> $GITHUB_OUTPUT fi fi fi # Parse language-specific configuration files config_version="" detected_package_manager="" case "$language" in "node") # Check package.json if [ -f package.json ] && command -v jq >/dev/null 2>&1; then version=$(jq -r '.engines.node // empty' package.json 2>/dev/null | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p') if [ -n "$version" ] && validate_version "$version"; then echo "Found Node.js version in package.json: $version" >&2 config_version="$version" fi fi # Detect package manager if [ -f bun.lockb ]; then detected_package_manager="bun" elif [ -f pnpm-lock.yaml ]; then detected_package_manager="pnpm" elif [ -f yarn.lock ]; then detected_package_manager="yarn" elif [ -f package-lock.json ]; then detected_package_manager="npm" elif [ -f package.json ] && command -v jq >/dev/null 2>&1; then # Check packageManager field in package.json pkg_manager=$(jq -r '.packageManager // empty' package.json 2>/dev/null | sed 's/@.*//') if [ -n "$pkg_manager" ]; then detected_package_manager="$pkg_manager" else detected_package_manager="npm" fi else detected_package_manager="npm" fi ;; "php") # Check composer.json if [ -f composer.json ] && command -v jq >/dev/null 2>&1; then # Try require.php first version=$(jq -r '.require.php // empty' composer.json 2>/dev/null | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p') if [ -z "$version" ]; then # Try platform.php version=$(jq -r '.config.platform.php // empty' composer.json 2>/dev/null | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p') fi if [ -n "$version" ] && validate_version "$version"; then echo "Found PHP version in composer.json: $version" >&2 config_version="$version" fi fi # Check phpunit.xml if [ -z "$config_version" ]; then phpunit_file="" if [ -f phpunit.xml ]; then phpunit_file="phpunit.xml" elif [ -f phpunit.xml.dist ]; then phpunit_file="phpunit.xml.dist" fi if [ -n "$phpunit_file" ]; then version=$(grep -o 'php[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"' "$phpunit_file" | sed -n 's/.*"\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\)".*/\1/p') if [ -n "$version" ] && validate_version "$version"; then echo "Found PHP version in $phpunit_file: $version" >&2 config_version="$version" fi fi fi # Detect package manager if [ -f composer.json ]; then detected_package_manager="composer" fi ;; "python") # Check pyproject.toml if [ -f pyproject.toml ]; then # Try PEP 621 requires-python first (allow leading whitespace) if command -v jq >/dev/null 2>&1 && grep -q '^\[project\]' pyproject.toml; then # Convert TOML to JSON for PEP 621 parsing (basic approach) version=$(grep -A 20 '^\[project\]' pyproject.toml | grep -E '^\s*requires-python[[:space:]]*=' | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p' | head -1) if [ -n "$version" ] && validate_version "$version"; then echo "Found Python version in pyproject.toml [project] requires-python: $version" >&2 config_version="$version" fi fi # Fallback to legacy python field if no PEP 621 version found if [ -z "$config_version" ]; then version=$(grep -E '^python[[:space:]]*=' pyproject.toml | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p') if [ -n "$version" ] && validate_version "$version"; then echo "Found Python version in pyproject.toml: $version" >&2 config_version="$version" fi fi fi # Check setup.py for python_requires if [ -z "$config_version" ] && [ -f setup.py ]; then version=$(grep -o 'python_requires[[:space:]]*=[[:space:]]*['\''"].*['\''"]' setup.py | sed -n 's/[^0-9]*\([0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?\).*/\1/p') if [ -n "$version" ] && validate_version "$version"; then echo "Found Python version in setup.py: $version" >&2 config_version="$version" fi fi # Detect package manager if [ -f pyproject.toml ] && grep -q '\[tool\.poetry\]' pyproject.toml; then detected_package_manager="poetry" elif [ -f Pipfile ]; then detected_package_manager="pipenv" elif [ -f requirements.txt ]; then detected_package_manager="pip" else detected_package_manager="pip" fi ;; "go") # Check go.mod if [ -f go.mod ]; then version=$(grep -E '^go[[:space:]]+[0-9]' go.mod | awk '{print $2}' | head -1 || echo "") if [ -n "$version" ] && validate_version "$version"; then echo "Found Go version in go.mod: $version" >&2 config_version="$version" fi fi # Detect package manager if [ -f go.mod ]; then detected_package_manager="go" fi ;; "dotnet") # Check global.json if [ -f global.json ] && command -v jq >/dev/null 2>&1; then version=$(jq -r '.sdk.version // empty' global.json 2>/dev/null || echo "") if [ -n "$version" ] && validate_version "$version"; then echo "Found .NET version in global.json: $version" >&2 config_version="$version" fi fi # Check .csproj files if [ -z "$config_version" ]; then # Enable nullglob to handle case when no .csproj files exist shopt -s nullglob for csproj in *.csproj; do if [ -f "$csproj" ]; then # Handle both TargetFramework and TargetFrameworks, and handle -windows monikers version=$(grep -oE 'net[0-9]+\.[0-9]+(-[a-z]+)?' "$csproj" | sed -n 's/.*net\([0-9]\+\.[0-9]\+\).*/\1/p' | head -1) if [ -n "$version" ] && validate_version "$version"; then echo "Found .NET version in $csproj: $version" >&2 config_version="$version" break fi fi done # Disable nullglob after use shopt -u nullglob fi # Detect package manager detected_package_manager="dotnet" ;; esac # Set config-file-version output if [ -n "$config_version" ]; then echo "config-file-version=$config_version" >> $GITHUB_OUTPUT fi # Set package-manager output if [ -n "$detected_package_manager" ]; then echo "package-manager=$detected_package_manager" >> $GITHUB_OUTPUT fi # Determine final detected version with priority order # Priority order: version-file > config-file > tool-versions > dockerfile > devcontainer > default final_version=$(grep -E "^(version-file|config-file|tool-versions|dockerfile|devcontainer)-version=" $GITHUB_OUTPUT | tac | awk -F= 'NF>1 && $2!="" {print $2; exit}') # If no version found from any source, use default if [ -z "$final_version" ] && [ -n "$DEFAULT_VERSION" ]; then final_version="$DEFAULT_VERSION" echo "Using default $LANGUAGE version: $final_version" >&2 fi # Set final detected version if [ -n "$final_version" ]; then # Validate the final version against the regex if ! validate_version "$final_version"; then echo "::error::Detected version $final_version does not match validation regex" >&2 exit 1 fi echo "detected-version=$final_version" >> $GITHUB_OUTPUT echo "Final detected $LANGUAGE version: $final_version" >&2 else echo "No $LANGUAGE version detected" >&2 fi