From eba947bd0f298aa1b1ae0704b2ba54f6931c1fbb Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 3 May 2026 00:50:57 +0000 Subject: [PATCH] chore(ci): centralize tool version management into a single source of truth --- .forgejo/workflows/ci.yml | 77 ++++++++++++++----- .tool-versions | 8 ++ features/ci_workflow_validation.feature | 70 +++++++++++++++++ .../steps/ci_workflow_validation_steps.py | 76 ++++++++++++++++++ scripts/load-tool-versions.sh | 27 +++++++ 5 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 .tool-versions create mode 100755 scripts/load-tool-versions.sh diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e42c1e80c..0c609c5fd 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -6,12 +6,6 @@ on: pull_request: branches: [master, develop*] -env: - UV_VERSION: "0.8.0" - PYTHON_VERSION: "3.13" - NOX_DEFAULT_VENV_BACKEND: "uv" - HELM_VERSION: "v3.16.4" - # Logging policy (server-load reduction): # The Forgejo server stores every line a step streams to its console as the # live job log, and that aggregate volume across all controller-driven CI @@ -27,7 +21,40 @@ env: # always streamed — the pipeline greps for them. jobs: + load-versions: + runs-on: docker + container: + image: python:3.13-slim + outputs: + uv-version: ${{ steps.versions.outputs.uv-version }} + python-version: ${{ steps.versions.outputs.python-version }} + helm-version: ${{ steps.versions.outputs.helm-version }} + kubeconform-version: ${{ steps.versions.outputs.kubeconform-version }} + steps: + - name: Install Node.js (required by actions/checkout) + run: | + apt-get update && apt-get install -y -qq nodejs && rm -rf /var/lib/apt/lists/* + + - uses: actions/checkout@v4 + + - name: Load tool versions from .tool-versions + id: versions + run: | + # Source the tool versions file and export as GitHub Actions outputs + while IFS='=' read -r key value; do + # Skip empty lines and comments + [[ -z "$key" || "$key" =~ ^# ]] && continue + # Trim whitespace + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + # Convert to lowercase with hyphens for output names + output_key=$(echo "$key" | tr '[:upper:]_' '[:lower:]-') + echo "${output_key}=${value}" >> $GITHUB_OUTPUT + echo "Loaded: ${output_key}=${value}" + done < .tool-versions + lint: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -40,7 +67,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -74,6 +101,7 @@ jobs: retention-days: 30 typecheck: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -86,7 +114,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -119,6 +147,7 @@ jobs: retention-days: 30 security: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -131,7 +160,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -165,6 +194,7 @@ jobs: retention-days: 30 quality: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -177,7 +207,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -211,6 +241,7 @@ jobs: timeout-minutes: 30 unit_tests: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -231,6 +262,7 @@ jobs: - name: Install Helm CLI if: steps.helm-cache.outputs.cache-hit != 'true' run: | + HELM_VERSION="${{ needs.load-versions.outputs.helm-version }}" ARCH="amd64" HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" @@ -246,7 +278,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -285,6 +317,7 @@ jobs: timeout-minutes: 45 integration_tests: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -305,6 +338,7 @@ jobs: - name: Install Helm CLI if: steps.helm-cache.outputs.cache-hit != 'true' run: | + HELM_VERSION="${{ needs.load-versions.outputs.helm-version }}" ARCH="amd64" HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" @@ -320,7 +354,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -364,10 +398,10 @@ jobs: retention-days: 30 coverage: + needs: [load-versions, lint, typecheck, security, quality, unit_tests] runs-on: docker container: image: python:3.13-slim - needs: [lint, typecheck, security, quality, unit_tests] # Bound the job so a hung run fails cleanly with diagnostics instead of # being externally reaped to "no data". The parallel engine runs in # ~4min; this leaves generous headroom for install + chunk tail. @@ -401,7 +435,7 @@ jobs: - name: Install uv and nox if: steps.gate.outputs.run == 'true' run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages if: steps.gate.outputs.run == 'true' @@ -460,6 +494,7 @@ jobs: retention-days: 30 build: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -472,7 +507,7 @@ jobs: - name: Install uv and nox run: | - pip install -q uv==${{ env.UV_VERSION }} nox + pip install -q uv==${{ needs.load-versions.outputs.uv-version }} nox - name: Cache uv packages uses: actions/cache@v3 @@ -508,7 +543,7 @@ jobs: # continue-on-error: true allows the status-check gate to pass even # when the docker:dind runner is unavailable (infrastructure issue). # The docker job still runs; only infrastructure failures are tolerated. - needs: [lint, typecheck, security, quality, unit_tests] + needs: [load-versions, lint, typecheck, security, quality, unit_tests] continue-on-error: true runs-on: docker container: @@ -580,6 +615,7 @@ jobs: retention-days: 30 helm: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -602,6 +638,7 @@ jobs: - name: Install Helm CLI if: steps.helm-cache.outputs.cache-hit != 'true' run: | + HELM_VERSION="${{ needs.load-versions.outputs.helm-version }}" ARCH="amd64" HELM_TARBALL="helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" curl -fsSL "https://get.helm.sh/${HELM_TARBALL}" -o "/tmp/${HELM_TARBALL}" @@ -615,7 +652,7 @@ jobs: - name: Install kubeconform run: | - KUBECONFORM_VERSION="v0.7.0" + KUBECONFORM_VERSION="${{ needs.load-versions.outputs.kubeconform-version }}" ARCH="amd64" KUBECONFORM_TARBALL="kubeconform-linux-${ARCH}.tar.gz" curl -fsSL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/${KUBECONFORM_TARBALL}" \ @@ -792,13 +829,14 @@ jobs: retention-days: 30 status-check: if: always() - needs: [lint, typecheck, security, quality, unit_tests, integration_tests, coverage, build, docker, helm, push-validation] + needs: [load-versions, lint, typecheck, security, quality, unit_tests, integration_tests, coverage, build, docker, helm, push-validation] runs-on: docker container: image: python:3.13-slim steps: - name: Check required job results run: | + echo "load-versions: ${{ needs.load-versions.result }}" echo "lint: ${{ needs.lint.result }}" echo "typecheck: ${{ needs.typecheck.result }}" echo "security: ${{ needs.security.result }}" @@ -811,7 +849,8 @@ jobs: echo "helm: ${{ needs.helm.result }}" echo "push-validation: ${{ needs.push-validation.result }}" - if [ "${{ needs.lint.result }}" != "success" ] || \ + if [ "${{ needs.load-versions.result }}" != "success" ] || \ + [ "${{ needs.lint.result }}" != "success" ] || \ [ "${{ needs.typecheck.result }}" != "success" ] || \ [ "${{ needs.security.result }}" != "success" ] || \ [ "${{ needs.quality.result }}" != "success" ] || \ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..77220cd53 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,8 @@ +# Tool versions for CI pipeline +# This file is the single source of truth for tool versions used in the CI pipeline. +# Format: TOOL_NAME=version + +UV_VERSION=0.8.0 +PYTHON_VERSION=3.13 +HELM_VERSION=v3.16.4 +KUBECONFORM_VERSION=v0.7.0 diff --git a/features/ci_workflow_validation.feature b/features/ci_workflow_validation.feature index 0e124184b..f76b7dca6 100644 --- a/features/ci_workflow_validation.feature +++ b/features/ci_workflow_validation.feature @@ -186,3 +186,73 @@ Feature: CI workflow validation Given the CI workflow file at ".forgejo/workflows/ci.yml" When I parse the CI workflow YAML Then the job "status-check" should depend on "helm" + + # --- Tool version centralization --- + + Scenario: Tool versions file exists + Given the tool versions file at ".tool-versions" + Then the tool versions file should exist + + Scenario: Tool versions file contains UV_VERSION + Given the tool versions file at ".tool-versions" + When I parse the tool versions file + Then the tool versions should contain "UV_VERSION" + + Scenario: Tool versions file contains PYTHON_VERSION + Given the tool versions file at ".tool-versions" + When I parse the tool versions file + Then the tool versions should contain "PYTHON_VERSION" + + Scenario: Tool versions file contains HELM_VERSION + Given the tool versions file at ".tool-versions" + When I parse the tool versions file + Then the tool versions should contain "HELM_VERSION" + + Scenario: Tool versions file contains KUBECONFORM_VERSION + Given the tool versions file at ".tool-versions" + When I parse the tool versions file + Then the tool versions should contain "KUBECONFORM_VERSION" + + Scenario: CI workflow reads UV_VERSION from tool versions file + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the workflow should have a job named "load-versions" + And the job "load-versions" should run "load tool versions from .tool-versions" + + Scenario: CI workflow load-versions job outputs uv-version + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "load-versions" should have output "uv-version" + + Scenario: CI workflow load-versions job outputs python-version + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "load-versions" should have output "python-version" + + Scenario: CI workflow load-versions job outputs helm-version + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "load-versions" should have output "helm-version" + + Scenario: CI workflow load-versions job outputs kubeconform-version + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "load-versions" should have output "kubeconform-version" + + Scenario: CI workflow jobs depend on load-versions + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "lint" should depend on "load-versions" + And the job "typecheck" should depend on "load-versions" + And the job "unit_tests" should depend on "load-versions" + And the job "integration_tests" should depend on "load-versions" + And the job "e2e_tests" should depend on "load-versions" + And the job "helm" should depend on "load-versions" + And the job "build" should depend on "load-versions" + + Scenario: CI workflow uses load-versions outputs for uv installation + Given the CI workflow file at ".forgejo/workflows/ci.yml" + When I parse the CI workflow YAML + Then the job "lint" should reference "needs.load-versions.outputs.uv-version" + And the job "typecheck" should reference "needs.load-versions.outputs.uv-version" + And the job "unit_tests" should reference "needs.load-versions.outputs.uv-version" diff --git a/features/steps/ci_workflow_validation_steps.py b/features/steps/ci_workflow_validation_steps.py index 39983d2cd..919cab517 100644 --- a/features/steps/ci_workflow_validation_steps.py +++ b/features/steps/ci_workflow_validation_steps.py @@ -189,3 +189,79 @@ def step_then_at_least_one_job_uses_cache(context): raise AssertionError( "No job in the CI workflow uses actions/cache for dependency caching" ) + + +@given('the tool versions file at "{path}"') +def step_given_tool_versions_file(context, path): + """Store the tool versions file path.""" + context.tool_versions_path = Path(path) + + +@then("the tool versions file should exist") +def step_then_tool_versions_exists(context): + """Verify the tool versions file exists.""" + if not context.tool_versions_path.exists(): + raise AssertionError( + f"Tool versions file not found at {context.tool_versions_path}" + ) + + +@when("I parse the tool versions file") +def step_when_parse_tool_versions(context): + """Parse the tool versions file.""" + tool_versions_path = context.tool_versions_path + if not tool_versions_path.exists(): + raise FileNotFoundError(f"Tool versions file not found at {tool_versions_path}") + + context.tool_versions = {} + with tool_versions_path.open("r") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + context.tool_versions[key.strip()] = value.strip() + + +@then('the tool versions should contain "{key}"') +def step_then_tool_versions_contain(context, key): + """Verify the tool versions file contains a specific key.""" + if key not in context.tool_versions: + raise AssertionError( + f"Tool version '{key}' not found in tool versions file. " + f"Available keys: {list(context.tool_versions.keys())}" + ) + + +@then('the job "{job_name}" should have output "{output_name}"') +def step_then_job_has_output(context, job_name, output_name): + """Verify a job has a specific output.""" + jobs = context.ci_workflow.get("jobs", {}) + job = jobs.get(job_name) + if job is None: + raise AssertionError(f"Job '{job_name}' not found in workflow") + + outputs = job.get("outputs", {}) + if output_name not in outputs: + raise AssertionError( + f"Output '{output_name}' not found in job '{job_name}'. " + f"Available outputs: {list(outputs.keys())}" + ) + + +@then('the job "{job_name}" should reference "{reference}"') +def step_then_job_references(context, job_name, reference): + """Verify a job references a specific value (e.g., needs.load-versions.outputs.uv-version).""" + jobs = context.ci_workflow.get("jobs", {}) + job = jobs.get(job_name) + if job is None: + raise AssertionError(f"Job '{job_name}' not found in workflow") + + # Convert the job to a string and search for the reference + job_str = str(job) + if reference not in job_str: + raise AssertionError( + f"Reference '{reference}' not found in job '{job_name}'" + ) diff --git a/scripts/load-tool-versions.sh b/scripts/load-tool-versions.sh new file mode 100755 index 000000000..7402e2595 --- /dev/null +++ b/scripts/load-tool-versions.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Load tool versions from .tool-versions file +# This script sources the .tool-versions file and exports the variables + +set -e + +TOOL_VERSIONS_FILE=".tool-versions" + +if [ ! -f "$TOOL_VERSIONS_FILE" ]; then + echo "ERROR: $TOOL_VERSIONS_FILE not found" + exit 1 +fi + +# Source the tool versions file +# This exports all TOOL_NAME=version pairs as environment variables +while IFS='=' read -r key value; do + # Skip empty lines and comments + [[ -z "$key" || "$key" =~ ^# ]] && continue + # Trim whitespace + key=$(echo "$key" | xargs) + value=$(echo "$value" | xargs) + export "$key=$value" +done < "$TOOL_VERSIONS_FILE" + +# Output the loaded versions for verification +echo "Loaded tool versions from $TOOL_VERSIONS_FILE:" +grep -v '^#' "$TOOL_VERSIONS_FILE" | grep -v '^$' | sed 's/^/ /'