diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e42c1e80c..9dfe8667b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,10 +7,7 @@ on: 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 @@ -27,7 +24,41 @@ 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: | + # load tool versions from .tool-versions (single source of truth) + # 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 +71,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 +105,7 @@ jobs: retention-days: 30 typecheck: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -86,7 +118,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 +151,7 @@ jobs: retention-days: 30 security: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -131,7 +164,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 +198,7 @@ jobs: retention-days: 30 quality: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -177,7 +211,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 +245,7 @@ jobs: timeout-minutes: 30 unit_tests: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -231,6 +266,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 +282,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 +321,7 @@ jobs: timeout-minutes: 45 integration_tests: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -305,6 +342,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 +358,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 +402,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 +439,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 +498,7 @@ jobs: retention-days: 30 build: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -472,7 +511,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 +547,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 +619,7 @@ jobs: retention-days: 30 helm: + needs: load-versions runs-on: docker container: image: python:3.13-slim @@ -602,6 +642,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 +656,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 +833,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 +853,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..6a09e44fd 100644 --- a/features/ci_workflow_validation.feature +++ b/features/ci_workflow_validation.feature @@ -186,3 +186,72 @@ 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 "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..91d74e199 100644 --- a/features/steps/ci_workflow_validation_steps.py +++ b/features/steps/ci_workflow_validation_steps.py @@ -189,3 +189,77 @@ 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/^/ /'