From 90eea8b54ac4cb795cdea4ed51a88e4829be05a6 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Mon, 8 Sep 2025 12:27:26 +0200 Subject: [PATCH 01/12] refactor: update GitHub workflows for release management and image building - Added new workflows for creating releases, preparing releases, and publishing libraries on merge. - Implemented logic to derive version from pull request titles and create Git tags/releases accordingly. - Enhanced image building workflows to include digest capturing and improved error handling. - Refactored existing workflows to streamline the process of bumping versions for internal libraries and services. - Introduced scripts for bumping chart versions and updating pyproject dependencies. - Removed obsolete scripts and workflows to clean up the repository. --- .github/workflows/build-images.yml | 110 ++++++++++++++ .github/workflows/create-release.yml | 47 ++++++ .github/workflows/lint-and-test.yml | 8 +- .github/workflows/prepare-release.yml | 74 ++++++++++ .github/workflows/publish-chart.yml | 95 ++++++++++++ .github/workflows/publish-libs-on-merge.yml | 149 +++++++++++++++++++ .github/workflows/semantic-release.yml | 156 -------------------- services/admin-backend/pyproject.toml | 15 +- services/document-extractor/pyproject.toml | 16 +- services/mcp-server/pyproject.toml | 2 +- services/rag-backend/pyproject.toml | 14 +- tools/bump_chart_versions.py | 54 +++++++ tools/bump_pyproject_deps.py | 115 +++++++++++++++ tools/update-helm-values.py | 72 --------- 14 files changed, 687 insertions(+), 240 deletions(-) create mode 100644 .github/workflows/build-images.yml create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/publish-chart.yml create mode 100644 .github/workflows/publish-libs-on-merge.yml delete mode 100644 .github/workflows/semantic-release.yml create mode 100644 tools/bump_chart_versions.py create mode 100644 tools/bump_pyproject_deps.py delete mode 100644 tools/update-helm-values.py diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 00000000..ebd21ffb --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,110 @@ +name: build-images +run-name: build-images ${{ github.event.release.tag_name }} +on: + release: + types: [published] + +permissions: + contents: read + packages: write + +jobs: + prepare: + if: ${{ github.event_name == 'release' }} + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release_tag.outputs.tag }} + version: ${{ steps.release_tag.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Resolve release tag & version + id: release_tag + run: | + git fetch --tags --force + TAG="${{ github.event.release.tag_name }}" + if [ -z "$TAG" ]; then + echo "No Git tag found to check out" >&2 + exit 1 + fi + VER_NO_V="${TAG#v}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "version=$VER_NO_V" >> $GITHUB_OUTPUT + + build-image: + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: rag-backend + dockerfile: services/rag-backend/Dockerfile + - name: admin-backend + dockerfile: services/admin-backend/Dockerfile + - name: document-extractor + dockerfile: services/document-extractor/Dockerfile + - name: mcp-server + dockerfile: services/mcp-server/Dockerfile + - name: frontend + dockerfile: services/frontend/apps/chat-app/Dockerfile + - name: admin-frontend + dockerfile: services/frontend/apps/admin-app/Dockerfile + env: + REGISTRY: ghcr.io + IMAGE_NS: ${{ github.repository }} + VERSION: ${{ needs.prepare.outputs.version }} + TAG: ${{ needs.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Checkout release tag + run: git checkout "$TAG" + - name: Normalize IMAGE_NS to lowercase + run: echo "IMAGE_NS=$(echo '${{ env.IMAGE_NS }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT }} + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + - name: Build & push ${{ matrix.name }} + run: | + docker buildx build --push \ + -t "$REGISTRY/$IMAGE_NS/${{ matrix.name }}:${VERSION}" \ + -t "$REGISTRY/$IMAGE_NS/${{ matrix.name }}:latest" \ + -f "${{ matrix.dockerfile }}" . + - name: Capture digest + run: | + sudo apt-get update && sudo apt-get install -y jq + ref="$REGISTRY/$IMAGE_NS/${{ matrix.name }}:${VERSION}" + digest=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest.Digest}}' | jq -r . || true) + jq -n --arg name "${{ matrix.name }}" --arg tag "$VERSION" --arg digest "$digest" '{($name): {tag: $tag, digest: $digest}}' > digest.json + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: image-digest-${{ matrix.name }} + path: digest.json + + collect-digests: + needs: [build-image] + runs-on: ubuntu-latest + steps: + - name: Download digest artifacts + uses: actions/download-artifact@v4 + with: + pattern: image-digest-* + merge-multiple: false + - name: Merge digests + run: | + sudo apt-get update && sudo apt-get install -y jq + jq -s 'reduce .[] as $item ({}; . * $item)' image-digest-*/digest.json > image-digests.json + - name: Upload merged digests + uses: actions/upload-artifact@v4 + with: + name: image-digests + path: image-digests.json diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..864eedc1 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,47 @@ +name: create-release +on: + pull_request_target: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + release: + if: >- + ${{ + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'refresh-locks') + }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Derive version from PR title + id: ver + run: | + TITLE="${{ github.event.pull_request.title }}" + VERSION=$(echo "$TITLE" | sed -nE 's/.*([0-9]+\.[0-9]+\.[0-9]+(\.post[0-9]+)?).*/\1/p' || true) + if [ -z "$VERSION" ]; then + echo "Could not extract version from PR title: $TITLE" >&2 + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Create Git tag + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag -a "v${{ steps.ver.outputs.version }}" -m "Release v${{ steps.ver.outputs.version }}" + git push origin "v${{ steps.ver.outputs.version }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.ver.outputs.version }} + name: v${{ steps.ver.outputs.version }} + generate_release_notes: true + token: ${{ secrets.GHCR_PAT }} diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 23e98420..7a4c9029 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -12,6 +12,12 @@ env: jobs: changes: + if: >- + ${{ + !contains(github.event.pull_request.labels.*.name, 'prepare-release') && + !contains(github.event.pull_request.labels.*.name, 'refresh-locks') && + !contains(github.event.pull_request.labels.*.name, 'chart-bump') + }} name: Detect Changes runs-on: ubuntu-latest outputs: @@ -81,7 +87,7 @@ jobs: - name: Build Docker image run: | - docker build -t $IMAGE_NAME --build-arg dev=1 -f services/${{ matrix.service }}/Dockerfile . + docker build -t $IMAGE_NAME -f services/${{ matrix.service }}/Dockerfile.dev . - name: Run linting run: | diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..7940e36c --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,74 @@ +name: prepare-release +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + if: >- + ${{ + github.event.pull_request.merged && + !contains(github.event.pull_request.labels.*.name, 'prepare-release') && + !contains(github.event.pull_request.labels.*.name, 'refresh-locks') && + !contains(github.event.pull_request.labels.*.name, 'chart-bump') + }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install semantic-release deps + run: npm ci + + - name: verify-dependencies-integrity + run: npm audit signatures + + - name: Compute next version (dry-run) + id: semrel + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx semantic-release --dry-run --no-ci | tee semrel.log + BASE_VERSION=$(grep -Eo "next release version is [0-9]+\.[0-9]+\.[0-9]+" semrel.log | awk '{print $5}') + if [ -z "$BASE_VERSION" ]; then echo "No new release required"; exit 1; fi + VERSION="${BASE_VERSION}.post$(date +%Y%m%d%H%M%S)" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install bump script deps + run: | + python -m pip install --upgrade pip + python -m pip install "tomlkit==0.13.3" "pyyaml==6.0.2" "packaging==25.0" + + - name: Bump internal libs only (no service pins) + run: | + python tools/bump_pyproject_deps.py --version "${{ steps.semrel.outputs.version }}" --bump-libs + + - name: Commit and open PR + uses: peter-evans/create-pull-request@v6 + with: + branch: chore/release-${{ steps.semrel.outputs.version }} + title: "chore(release): prepare ${{ steps.semrel.outputs.version }}" + body: | + Prepare release ${{ steps.semrel.outputs.version }} + - bump internal libs versions only + commit-message: "chore(release): prepare ${{ steps.semrel.outputs.version }}" + add-paths: | + libs/**/pyproject.toml + labels: prepare-release diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml new file mode 100644 index 00000000..89e62fa8 --- /dev/null +++ b/.github/workflows/publish-chart.yml @@ -0,0 +1,95 @@ +name: publish-chart +run-name: publish-chart (post-build-images) +on: + workflow_run: + workflows: [build-images] + types: [completed] + +permissions: + contents: write + pull-requests: write + packages: write + +jobs: + chart: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout release tag from triggering run + run: | + git fetch --tags --force + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + if [ -n "$HEAD_SHA" ]; then + TAG=$(git tag --points-at "$HEAD_SHA" | head -n 1 || true) + if [ -z "$TAG" ]; then + TAG=$(git describe --tags --abbrev=0 "$HEAD_SHA" 2>/dev/null || true) + fi + fi + if [ -z "$TAG" ]; then + echo "No tag found (head_sha=$HEAD_SHA)" >&2 + exit 1 + fi + git checkout "$TAG" + echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV + echo "APP_VERSION=${TAG#v}" >> $GITHUB_ENV + + - name: Expose app version + id: meta + run: echo "app_version=${APP_VERSION}" >> $GITHUB_OUTPUT + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Login to GHCR for Helm OCI + run: echo ${{ secrets.GHCR_PAT }} | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Setup Python (for bump script) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install bump script deps + run: | + python -m pip install --upgrade pip + python -m pip install "pyyaml==6.0.2" "packaging==25.0" + + - name: Bump Chart.yaml (set release version) + env: + APP_VERSION: ${{ env.APP_VERSION }} + run: | + python tools/bump_chart_versions.py --app-version "$APP_VERSION" + + - name: Package and push rag chart + env: + APP_VERSION: ${{ env.APP_VERSION }} + run: | + set -euo pipefail + export HELM_EXPERIMENTAL_OCI=1 + CHART_DIR="infrastructure/rag" + if [ ! -f "$CHART_DIR/Chart.yaml" ]; then + echo "Expected chart at $CHART_DIR/Chart.yaml not found" >&2 + exit 1 + fi + mkdir -p dist + helm dependency update "$CHART_DIR" || true + helm package "$CHART_DIR" --destination dist + PKG=$(ls dist/*.tgz) + helm show chart "$PKG" | grep -E "^version: " + helm push "$PKG" oci://ghcr.io/${{ github.repository_owner }}/charts + + - name: Create PR for chart version bumps + uses: peter-evans/create-pull-request@v6 + with: + base: main + branch: chore/chart-bump-${{ steps.meta.outputs.app_version }} + title: "chore(release): bump chart versions to ${{ steps.meta.outputs.app_version }}" + body: | + Persist Chart.yaml appVersion/version to match release ${{ steps.meta.outputs.app_version }}. + commit-message: "chore(release): bump charts to ${{ steps.meta.outputs.app_version }}" + add-paths: | + infrastructure/**/Chart.yaml + labels: chart-bump diff --git a/.github/workflows/publish-libs-on-merge.yml b/.github/workflows/publish-libs-on-merge.yml new file mode 100644 index 00000000..95dec1b9 --- /dev/null +++ b/.github/workflows/publish-libs-on-merge.yml @@ -0,0 +1,149 @@ +name: publish-libs-on-merge +on: + pull_request: + branches: [main] + types: [closed] + +permissions: + contents: write + pull-requests: write + packages: write + issues: write + +jobs: + publish: + if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'prepare-release') }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Derive version from PR title + id: ver + run: | + TITLE="${{ github.event.pull_request.title }}" + VERSION=$(echo "$TITLE" | sed -nE 's/.*prepare ([0-9]+\.[0-9]+\.[0-9]+(\.post[0-9]+)?).*/\1/p' || true) + if [ -z "$VERSION" ]; then + echo "Could not derive version from PR title" >&2 + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + run: | + pip install poetry==2.1.3 + - name: Configure TestPyPI repository #TODO: should be pypi later. + run: | + poetry config repositories.testpypi https://test.pypi.org/legacy/ + - name: Build and publish libs to TestPyPI #TODO: if STACKIT org is created, the gha should be authorized, no token necessary anymore! + env: + POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ + POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} + run: | + for lib in libs/*; do + [ -d "$lib" ] || continue + echo "Publishing $lib" + (cd "$lib" && poetry version "${{ steps.ver.outputs.version }}" && poetry build && poetry publish -r testpypi) + done + lock-services: + runs-on: ubuntu-latest + needs: publish + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + run: | + pip install poetry==2.1.3 + - name: Install bump script deps + run: | + python -m pip install --upgrade pip + python -m pip install "tomlkit==0.13.3" + - name: Update service dependency pins to released version + env: + VERSION: ${{ needs.publish.outputs.version }} + run: | + python tools/bump_pyproject_deps.py --version "$VERSION" --bump-service-pins + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Wait for TestPyPI indexing + env: + VERSION: ${{ needs.publish.outputs.version }} + run: | + echo "Waiting for TestPyPI to index internal libs for version $VERSION" + for name in admin-api-lib extractor-api-lib rag-core-api rag-core-lib; do + echo "Checking $name==$VERSION" + seen=false + for i in $(seq 1 60); do # up to ~5 minutes + json_ok=false + simple_ok=false + if curl -fsSL "https://test.pypi.org/pypi/$name/json" | jq -e --arg v "$VERSION" '.releases[$v] | length > 0' >/dev/null; then + json_ok=true + fi + # Check simple index also, Poetry resolves via /simple + if curl -fsSL "https://test.pypi.org/simple/$name/" | grep -q "$VERSION"; then + simple_ok=true + fi + if [ "$json_ok" = true ] && [ "$simple_ok" = true ]; then + echo "Found $name==$VERSION on JSON and /simple" + seen=true + break + fi + sleep 5 + done + if [ "$seen" != "true" ]; then + echo "Error: $name==$VERSION not visible on TestPyPI JSON+simple yet" + echo "--- Debug /simple page for $name ---" + curl -fsSL "https://test.pypi.org/simple/$name/" || true + exit 1 + fi + done + - name: Clear poetry caches + run: | + poetry cache clear --all pypi -n || true + poetry cache clear --all testpypi -n || true + + - name: Refresh service lockfiles + run: | + for svc in services/rag-backend services/admin-backend services/document-extractor services/mcp-server; do + if [ -f "$svc/pyproject.toml" ]; then + echo "Locking $svc" + ( + cd "$svc" + poetry lock -v || ( + echo "Lock failed, clearing caches and retrying..."; + poetry cache clear --all pypi -n || true; + poetry cache clear --all testpypi -n || true; + sleep 10; + poetry lock -v + ) + ) + fi + done + - name: Open PR with updated lockfiles and pins + id: cpr + uses: peter-evans/create-pull-request@v6 + with: + branch: chore/refresh-locks-${{ needs.publish.outputs.version }}-${{ github.run_number }} + title: "chore(release): refresh service lockfiles for ${{ needs.publish.outputs.version }}" + body: | + Refresh service poetry.lock files and dependency pins for version ${{ needs.publish.outputs.version }}. + commit-message: "chore(release): refresh service lockfiles and pins" + add-paths: | + services/**/pyproject.toml + services/**/poetry.lock + labels: refresh-locks diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml deleted file mode 100644 index c8a3f388..00000000 --- a/.github/workflows/semantic-release.yml +++ /dev/null @@ -1,156 +0,0 @@ -name: semantic-release -on: - workflow_dispatch: - push: - # Only trigger on merged PRs, not on every PR push - pull_request: - types: [closed] - branches: - - main - -permissions: - contents: write - packages: write - -jobs: - semantic-release: - name: semantic-release - runs-on: ubuntu-latest - # Only run on push to main, manual dispatch, or when PR is merged - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) - outputs: - new-release-published: ${{ steps.semantic-release.outputs.new-release-published }} - new-release-version: ${{ steps.semantic-release.outputs.new-release-version }} - steps: - - name: checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: false - - - name: setup-node - uses: actions/setup-node@v4 - with: - node-version: "22.13.1" - - - name: create-archives - run: | - TEMP_DIR=$(mktemp -d) - tar --warning=no-file-changed \ - --exclude=".git" \ - --exclude="/.git" \ - --exclude="node_modules" \ - -czf "$TEMP_DIR/action-main-release-trials.tar.gz" . - zip -r "$TEMP_DIR/action-main-release-trials.zip" . \ - -x ".git" "node_modules" - mv "$TEMP_DIR"/*.{tar.gz,zip} . - rm -rf "$TEMP_DIR" - - - name: install-dependencies - run: npm ci - - - name: verify-dependencies-integrity - run: npm audit signatures - - - name: create-semantic-release - id: semantic-release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Run semantic-release and capture the output - npx semantic-release > semantic-release-output.txt 2>&1 || true - - # Check if a new release was published by looking for the success message - if grep -q "Published release" semantic-release-output.txt; then - echo "new-release-published=true" >> $GITHUB_OUTPUT - - # Extract the version from the output - VERSION=$(grep "Published release" semantic-release-output.txt | sed -n 's/.*Published release \([0-9]\+\.[0-9]\+\.[0-9]\+\).*/\1/p') - - if [[ -n "$VERSION" ]]; then - echo "new-release-version=$VERSION" >> $GITHUB_OUTPUT - echo "āœ… New release published: $VERSION" - else - echo "āŒ Could not extract version from semantic-release output" - exit 1 - fi - else - echo "new-release-published=false" >> $GITHUB_OUTPUT - echo "ā„¹ļø No new release published" - fi - - build-and-push-images: - name: build-and-push-images - runs-on: ubuntu-latest - needs: semantic-release - if: needs.semantic-release.outputs.new-release-published == 'true' - strategy: - matrix: - service: - - name: rag-backend - dockerfile: services/rag-backend/Dockerfile - image: rag-backend - build-args: "dev=0" - - name: admin-backend - dockerfile: services/admin-backend/Dockerfile - image: admin-backend - build-args: "dev=0" - - name: document-extractor - dockerfile: services/document-extractor/Dockerfile - image: document-extractor - build-args: "dev=0" - - name: mcp-server - dockerfile: services/mcp-server/Dockerfile - image: mcp-server - build-args: "dev=0" - - name: frontend - dockerfile: services/frontend/apps/chat-app/Dockerfile - image: frontend - build-args: "" - - name: admin-frontend - dockerfile: services/frontend/apps/admin-app/Dockerfile - image: admin-frontend - build-args: "" - steps: - - name: debug-job-inputs - run: | - echo "šŸ” Debug: needs.semantic-release.outputs.new-release-published = ${{ needs.semantic-release.outputs.new-release-published }}" - echo "šŸ” Debug: needs.semantic-release.outputs.new-release-version = ${{ needs.semantic-release.outputs.new-release-version }}" - - - name: checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: false - - - name: login-to-github-container-registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: build-and-push-${{ matrix.service.name }} - run: | - docker build \ - --tag ghcr.io/${{ github.repository_owner }}/rag-template/${{ matrix.service.image }}:v${{ needs.semantic-release.outputs.new-release-version }} \ - --tag ghcr.io/${{ github.repository_owner }}/rag-template/${{ matrix.service.image }}:latest \ - --file ${{ matrix.service.dockerfile }} \ - . - - docker push ghcr.io/${{ github.repository_owner }}/rag-template/${{ matrix.service.image }}:v${{ needs.semantic-release.outputs.new-release-version }} - docker push ghcr.io/${{ github.repository_owner }}/rag-template/${{ matrix.service.image }}:latest - - - name: deployment-summary - if: strategy.job-index == 0 # Only run on first job in matrix - run: | - echo "## šŸš€ Deployment Summary" >> $GITHUB_STEP_SUMMARY - echo "**New version:** v${{ needs.semantic-release.outputs.new-release-version }}" >> $GITHUB_STEP_SUMMARY - echo "**Services built and deployed:**" >> $GITHUB_STEP_SUMMARY - echo "- rag-backend" >> $GITHUB_STEP_SUMMARY - echo "- admin-backend" >> $GITHUB_STEP_SUMMARY - echo "- document-extractor" >> $GITHUB_STEP_SUMMARY - echo "- mcp-server" >> $GITHUB_STEP_SUMMARY - echo "- frontend" >> $GITHUB_STEP_SUMMARY - echo "- admin-frontend" >> $GITHUB_STEP_SUMMARY - echo "**Registry:** ghcr.io/${{ github.repository_owner }}/rag-template" >> $GITHUB_STEP_SUMMARY diff --git a/services/admin-backend/pyproject.toml b/services/admin-backend/pyproject.toml index c6acd17c..eb8b658e 100644 --- a/services/admin-backend/pyproject.toml +++ b/services/admin-backend/pyproject.toml @@ -51,8 +51,8 @@ skip_gitignore = true max-line-length = 120 [tool.poetry] -name = "admin_backend" -version = "0.0.1" +name = "admin-backend" +version = "2.2.1" description = "The admin backend is responsible for the document management. This includes deletion, upload and getting particular documents or document lists." authors = ["STACKIT Data and AI Consulting "] readme = "README.md" @@ -90,4 +90,13 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.dependencies] python = "^3.13" -admin-api-lib = {path = "../../libs/admin-api-lib", develop = true} + +# Prefer PyPI, but allow resolving from TestPyPI for internal libs +[[tool.poetry.source]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +priority = "supplemental" + +[tool.poetry.group.prod.dependencies] +admin-api-lib = "==2.2.1" +rag-core-lib = "==2.2.1" diff --git a/services/document-extractor/pyproject.toml b/services/document-extractor/pyproject.toml index 59769f29..82c531d7 100644 --- a/services/document-extractor/pyproject.toml +++ b/services/document-extractor/pyproject.toml @@ -43,15 +43,14 @@ skip_gitignore = true max-line-length = 120 [tool.poetry] -name = "pdfextractor_server" -version = "0.0.0" -description = "Extracts the content of pdf documents." +name = "document-extractor" +version = "2.2.1" +description = "Extracts content from files and sources like Confluence spaces, sitemaps etc." authors = ["STACKIT Data and AI Consulting "] readme = "README.md" [tool.poetry.dependencies] python = "^3.13" -extractor-api-lib = {path = "../../libs/extractor-api-lib", develop = true} [tool.poetry.group.dev.dependencies] flake8 = "^7.2.0" @@ -86,3 +85,12 @@ httpx = "^0.28.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +# Prefer PyPI, but allow resolving from TestPyPI for internal libs +[[tool.poetry.source]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +priority = "supplemental" + +[tool.poetry.group.prod.dependencies] +extractor-api-lib = "==2.2.1" diff --git a/services/mcp-server/pyproject.toml b/services/mcp-server/pyproject.toml index 95fb4949..2b76b417 100644 --- a/services/mcp-server/pyproject.toml +++ b/services/mcp-server/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rag-mcp-server" -version = "1.0.0" +version = "2.2.1" description = "Offers to use the chat interface of the RAG using MCP" authors = ["STACKIT Data and AI Consulting "] diff --git a/services/rag-backend/pyproject.toml b/services/rag-backend/pyproject.toml index 1e790e11..5a94c2bf 100644 --- a/services/rag-backend/pyproject.toml +++ b/services/rag-backend/pyproject.toml @@ -1,12 +1,11 @@ [tool.poetry] -name = "rag-usecase-example" -version = "0.1.0" +name = "rag-backend" +version = "2.2.1" description = "" authors = ["STACKIT Data and AI Consulting "] [tool.poetry.dependencies] python = "^3.13" -rag-core-api = { path = "../../libs/rag-core-api", develop = true} [tool.poetry.group.dev.dependencies] debugpy = "^1.8.14" @@ -89,3 +88,12 @@ skip_gitignore = true [tool.pylint] max-line-length = 120 +# Prefer PyPI, but allow resolving from TestPyPI for internal libs +[[tool.poetry.source]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +priority = "supplemental" + +[tool.poetry.group.prod.dependencies] +rag-core-api = "==2.2.1" +rag-core-lib = "==2.2.1" diff --git a/tools/bump_chart_versions.py b/tools/bump_chart_versions.py new file mode 100644 index 00000000..9c4da5a0 --- /dev/null +++ b/tools/bump_chart_versions.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import argparse +import pathlib +import sys +import re + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _to_chart_version(app_version: str) -> str: + """Convert app_version to a SemVer 2.0.0 compliant Helm chart version. + + Examples: + - "2.0.0.post20250904105936" -> "2.0.0-post.20250904105936" + - "2.0.1" -> "2.0.1" + - "2.0.1-rc.1" -> "2.0.1-rc.1" + - Fallback: if an unexpected format is provided, try to keep a valid semver + by extracting the leading MAJOR.MINOR.PATCH. + """ + # Case 1: our prepare-release format "X.Y.Z.post" + m = re.fullmatch(r"(?P\d+\.\d+\.\d+)\.post(?P\d+)", app_version) + if m: + return f"{m.group('base')}-post.{m.group('ts')}" + + # Case 2: already valid semver (optionally with pre-release or build metadata) + if re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", app_version): + return app_version + + # Fallback: keep only the base version if present + base = re.match(r"(\d+\.\d+\.\d+)", app_version) + return base.group(1) if base else app_version + + +def bump_chart(chart_path: pathlib.Path, app_version: str): + data = yaml.safe_load(chart_path.read_text()) + data['appVersion'] = str(app_version) + data['version'] = _to_chart_version(str(app_version)) + chart_path.write_text(yaml.safe_dump(data, sort_keys=False)) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--app-version', required=True) + args = p.parse_args() + + charts = list((ROOT / 'infrastructure').glob('*/Chart.yaml')) + for ch in charts: + bump_chart(ch, args.app_version) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/bump_pyproject_deps.py b/tools/bump_pyproject_deps.py new file mode 100644 index 00000000..8e3688b8 --- /dev/null +++ b/tools/bump_pyproject_deps.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import argparse +import pathlib +import re +import sys +from typing import Any, List, Optional + +import tomlkit + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +# Only bump versions for internal libs here +LIBS_VERSION_FILES = [ + ROOT / 'libs' / 'rag-core-lib' / 'pyproject.toml', + ROOT / 'libs' / 'rag-core-api' / 'pyproject.toml', + ROOT / 'libs' / 'admin-api-lib' / 'pyproject.toml', + ROOT / 'libs' / 'extractor-api-lib' / 'pyproject.toml', +] + +# Service pins to update after libs are published +SERVICE_PINS = { + ROOT / 'services' / 'rag-backend' / 'pyproject.toml': { + 'tool.poetry.group.prod.dependencies.rag-core-api': '=={v}', + 'tool.poetry.group.prod.dependencies.rag-core-lib': '=={v}', + }, + ROOT / 'services' / 'admin-backend' / 'pyproject.toml': { + 'tool.poetry.group.prod.dependencies.admin-api-lib': '=={v}', + 'tool.poetry.group.prod.dependencies.rag-core-lib': '=={v}', + }, + ROOT / 'services' / 'document-extractor' / 'pyproject.toml': { + 'tool.poetry.group.prod.dependencies.extractor-api-lib': '=={v}', + }, +} + + +def replace_version_line(text: str, new_version: str) -> str: + lines = text.splitlines(keepends=True) + in_tool_poetry = False + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith('[tool.poetry]'): + in_tool_poetry = True + continue + if in_tool_poetry and stripped.startswith('[') and not stripped.startswith('[tool.poetry]'): + # left the section without finding version; stop scanning section + break + if in_tool_poetry and stripped.startswith('version'): + # Replace only the version value, keep indentation and spacing + lines[i] = re.sub(r'version\s*=\s*"[^"]*"', f'version = "{new_version}"', line) + return ''.join(lines) + # If no version line found, append it to the [tool.poetry] section + out = ''.join(lines) + return out + f"\n[tool.poetry]\nversion = \"{new_version}\"\n" + + +def _get_table(doc: tomlkit.TOMLDocument, path: List[str]) -> Optional[Any]: + ref: Any = doc + for key in path: + try: + if key not in ref: # mapping-like check + return None + ref = ref[key] + except Exception: + return None + return ref + + +def bump(version: str, bump_libs: bool = True, bump_service_pins: bool = True): + # 1) bump libs versions (textual, non-destructive) + if bump_libs: + for file in LIBS_VERSION_FILES: + txt = file.read_text() + new_txt = replace_version_line(txt, version) + file.write_text(new_txt) + print(f"Updated {file} -> tool.poetry.version = {version}") + + # 2) bump service pins only inside [tool.poetry.group.prod.dependencies] + if bump_service_pins: + for file, mapping in SERVICE_PINS.items(): + txt = file.read_text() + doc = tomlkit.parse(txt) + deps = _get_table(doc, [ + 'tool', 'poetry', 'group', 'prod', 'dependencies' + ]) + if deps is None or not hasattr(deps, '__contains__'): + print(f"Skip {file}: prod dependencies table not found") + file.write_text(tomlkit.dumps(doc)) + continue + for dotted, template in mapping.items(): + pkg = dotted.split('.')[-1] + if pkg in deps: + val = template.format(v=version) + deps[pkg] = val + print(f"Pinned {file} -> {pkg} = {val}") + else: + print(f"Skip {file}: {pkg} not present in prod dependencies") + file.write_text(tomlkit.dumps(doc)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--version', required=True) + ap.add_argument('--bump-libs', action='store_true', help='Bump versions in internal libs only') + ap.add_argument('--bump-service-pins', action='store_true', help='Bump service dependency pins only') + args = ap.parse_args() + + # Backward compatibility: if neither flag is provided, do both + bump_libs = args.bump_libs or (not args.bump_libs and not args.bump_service_pins) + bump_service_pins = args.bump_service_pins or (not args.bump_libs and not args.bump_service_pins) + + bump(args.version, bump_libs=bump_libs, bump_service_pins=bump_service_pins) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/update-helm-values.py b/tools/update-helm-values.py deleted file mode 100644 index 283ad051..00000000 --- a/tools/update-helm-values.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -""" -Update Helm values.yaml with new version tags for specific services only, -preserving all existing formatting and disabling line wrapping via ruamel.yaml. -""" - -import sys -from ruamel.yaml import YAML -from ruamel.yaml.scalarstring import DoubleQuotedScalarString - -def update_helm_values(file_path, new_version): - """Update specific service image tags in values.yaml with ruamel.yaml.""" - yaml = YAML() - yaml.preserve_quotes = True - yaml.width = float('inf') # ← no wrapping, even for long lines - yaml.indent(mapping=2, sequence=4, offset=2) - - with open(file_path, 'r') as f: - data = yaml.load(f) - - services_to_update = [ - ('backend', 'mcp', 'image', 'tag'), - ('backend', 'image', 'tag'), - ('frontend', 'image', 'tag'), - ('adminBackend', 'image', 'tag'), - ('extractor', 'image', 'tag'), - ('adminFrontend', 'image', 'tag'), - ] - - new_tag = f"v{new_version}" - updated_count = 0 - - for path in services_to_update: - node = data - try: - for key in path[:-1]: - node = node[key] - tag_key = path[-1] - if tag_key in node: - old = node[tag_key] - if str(old) != new_tag: - if hasattr(old, 'style') and old.style in ('"', "'"): - node[tag_key] = DoubleQuotedScalarString(new_tag) - else: - node[tag_key] = new_tag - print(f"āœ… Updated {'.'.join(path)}: {old!r} → {new_tag!r}") - updated_count += 1 - else: - print(f"āš ļø {'.'.join(path)} already at {new_tag!r}") - else: - print(f"āŒ Could not find {'.'.join(path)}") - except (KeyError, TypeError) as e: - print(f"āŒ Could not access {'.'.join(path)}: {e}") - - if updated_count: - with open(file_path, 'w') as f: - yaml.dump(data, f) - print(f"\nāœ… Updated {updated_count} tag(s) in {file_path}") - return True - else: - print(f"\nāš ļø No changes needed in {file_path}") - return False - -if __name__ == '__main__': - if len(sys.argv) != 2: - print("Usage: update-helm-values.py ") - sys.exit(1) - - version = sys.argv[1] - file_path = 'infrastructure/rag/values.yaml' - success = update_helm_values(file_path, version) - sys.exit(0 if success else 1) From df751928b6f2ae74e972e80467dcd60602eeb92f Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 12:28:02 +0100 Subject: [PATCH 02/12] fix: improve logging messages in PDFExtractor for table conversion errors --- .../file_extractors/pdf_extractor.py | 6 ++-- services/mcp-server/src/rag_mcp_server.py | 32 +------------------ 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/libs/extractor-api-lib/src/extractor_api_lib/impl/extractors/file_extractors/pdf_extractor.py b/libs/extractor-api-lib/src/extractor_api_lib/impl/extractors/file_extractors/pdf_extractor.py index f8caa54b..9d807528 100644 --- a/libs/extractor-api-lib/src/extractor_api_lib/impl/extractors/file_extractors/pdf_extractor.py +++ b/libs/extractor-api-lib/src/extractor_api_lib/impl/extractors/file_extractors/pdf_extractor.py @@ -196,7 +196,7 @@ def _extract_tables_from_text_page( try: converted_table = self._dataframe_converter.convert(table_df) except TypeError: - logger.exception("Error while converting table to string.") + logger.exception("Error while converting table to string") continue if not converted_table.strip(): continue @@ -321,10 +321,10 @@ def _extract_tables_from_scanned_page( ) ) except Exception: - logger.warning("Failed to convert Camelot table %d.", i + 1, exc_info=True) + logger.warning("Failed to convert Camelot table %d", i + 1, exc_info=True) except Exception: - logger.debug("Camelot table extraction failed for page %d.", page_index, exc_info=True) + logger.debug("Camelot table extraction failed for page %d", page_index, exc_info=True) return table_elements diff --git a/services/mcp-server/src/rag_mcp_server.py b/services/mcp-server/src/rag_mcp_server.py index 25e9820e..bf35bb5d 100644 --- a/services/mcp-server/src/rag_mcp_server.py +++ b/services/mcp-server/src/rag_mcp_server.py @@ -46,20 +46,6 @@ def run(self): @extensible_docstring("chat_simple") async def chat_simple(self, session_id: str, message: str) -> str: - """Handle a simple chat request. - - Parameters - ---------- - session_id : str - The ID of the user session. - message : str - The user message to process. - - Returns - ------- - str - The response from the chat model. - """ chat_request = ChatRequest(message=message) response = await self._handle_chat(session_id, chat_request) return response.answer @@ -67,23 +53,7 @@ async def chat_simple(self, session_id: str, message: str) -> str: @extensible_docstring("chat_with_history") async def chat_with_history( self, session_id: str, message: str, history: list[dict[str, str]] = None - ) -> dict[str, Any]: - """Handle a chat request with history. - - Parameters - ---------- - session_id : str - The ID of the user session. - message : str - The user message to process. - history : list[dict[str, str]], optional - The chat history to include in the request. - - Returns - ------- - dict[str, Any] - The response from the chat model. - """ + ) -> dict[str, Any]: # Build chat history if provided chat_history = None if history: From 98c54d6c3b55a63f417febb9dcae3ba281e14cdb Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 12:28:56 +0100 Subject: [PATCH 03/12] fix: correct indentation in chat_with_history method signature --- services/mcp-server/src/rag_mcp_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/mcp-server/src/rag_mcp_server.py b/services/mcp-server/src/rag_mcp_server.py index bf35bb5d..b5fb2d04 100644 --- a/services/mcp-server/src/rag_mcp_server.py +++ b/services/mcp-server/src/rag_mcp_server.py @@ -53,7 +53,7 @@ async def chat_simple(self, session_id: str, message: str) -> str: @extensible_docstring("chat_with_history") async def chat_with_history( self, session_id: str, message: str, history: list[dict[str, str]] = None - ) -> dict[str, Any]: + ) -> dict[str, Any]: # Build chat history if provided chat_history = None if history: From 969fbd080f4044270f119c89e187b64515f983e1 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 14:02:48 +0100 Subject: [PATCH 04/12] chore: update poetry.lock files to refine package groups and versions --- libs/rag-core-api/poetry.lock | 8 ++++---- libs/rag-core-lib/poetry.lock | 4 ++-- services/mcp-server/poetry.lock | 10 +++++----- services/rag-backend/poetry.lock | 28 ++++------------------------ 4 files changed, 15 insertions(+), 35 deletions(-) diff --git a/libs/rag-core-api/poetry.lock b/libs/rag-core-api/poetry.lock index e20632d2..fb4a10da 100644 --- a/libs/rag-core-api/poetry.lock +++ b/libs/rag-core-api/poetry.lock @@ -1932,7 +1932,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["main", "test"] +groups = ["test"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -3569,7 +3569,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["main", "test"] +groups = ["test"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -4121,7 +4121,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["main", "test"] +groups = ["test"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -4143,7 +4143,7 @@ version = "0.26.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" -groups = ["main", "test"] +groups = ["test"] files = [ {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, diff --git a/libs/rag-core-lib/poetry.lock b/libs/rag-core-lib/poetry.lock index 326a0e4a..419eca92 100644 --- a/libs/rag-core-lib/poetry.lock +++ b/libs/rag-core-lib/poetry.lock @@ -3076,7 +3076,7 @@ name = "pytest-asyncio" version = "1.2.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" groups = ["test"] files = [ {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, @@ -3084,7 +3084,7 @@ files = [ ] [package.dependencies] -pytest = ">=8.2,<10" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] diff --git a/services/mcp-server/poetry.lock b/services/mcp-server/poetry.lock index 4362eff3..30e0173b 100644 --- a/services/mcp-server/poetry.lock +++ b/services/mcp-server/poetry.lock @@ -3201,14 +3201,14 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.48.0" +version = "0.50.0" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, - {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, + {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, + {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, ] [package.dependencies] @@ -3621,4 +3621,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "ab0db57466b51c69e54da327ea2aac110ed0aa9a4ef72adff0424daa2d5611e0" +content-hash = "e71e526a71bc09ea6de40790233e7c76d6ff30953623181a96d2b2f601860c4a" diff --git a/services/rag-backend/poetry.lock b/services/rag-backend/poetry.lock index 0d6c7316..a238a659 100644 --- a/services/rag-backend/poetry.lock +++ b/services/rag-backend/poetry.lock @@ -469,7 +469,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\"", lint = "platform_system == \"Windows\"", prod = "sys_platform == \"win32\" or platform_system == \"Windows\"", prod-local = "sys_platform == \"win32\" or platform_system == \"Windows\"", test = "sys_platform == \"win32\""} +markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", lint = "platform_system == \"Windows\"", prod = "platform_system == \"Windows\" or sys_platform == \"win32\"", prod-local = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""} [[package]] name = "coloredlogs" @@ -1895,7 +1895,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["dev", "prod", "prod-local", "test"] +groups = ["test"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -3665,7 +3665,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev", "prod", "prod-local", "test"] +groups = ["test"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -4214,7 +4214,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev", "prod", "prod-local", "test"] +groups = ["test"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -4230,25 +4230,6 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] -[[package]] -name = "pytest-asyncio" -version = "0.26.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev", "prod", "prod-local"] -files = [ - {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, - {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -4457,7 +4438,6 @@ langgraph = "^1.0.3" langgraph-checkpoint = ">=3.0.0,<4.0.0" openai = "^1.77.0" pillow = "^11.2.1" -pytest-asyncio = "^0.26.0" pyyaml = "^6.0.2" qdrant-client = "^1.14.2" rag-core-lib = {path = "../rag-core-lib", develop = true} From de2391c0980fc9fdd73643e3cea5afac0030bf0a Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 14:10:54 +0100 Subject: [PATCH 05/12] chore: streamline Dockerfile environment variable settings for nonroot user --- services/document-extractor/Dockerfile | 3 +-- services/document-extractor/pyproject.toml | 2 +- services/mcp-server/Dockerfile | 11 +++-------- services/rag-backend/Dockerfile | 4 +--- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/services/document-extractor/Dockerfile b/services/document-extractor/Dockerfile index 0e97f295..9628486f 100644 --- a/services/document-extractor/Dockerfile +++ b/services/document-extractor/Dockerfile @@ -41,8 +41,7 @@ RUN adduser --disabled-password --gecos "" --uid 10001 nonroot ENV POETRY_VIRTUALENVS_PATH=/opt/.venv # Ensure poetry reuses existing virtualenv when running as nonroot -ENV POETRY_VIRTUALENVS_CREATE=false \ - POETRY_VIRTUALENVS_IN_PROJECT=false \ +ENV POETRY_VIRTUALENVS_IN_PROJECT=false \ VIRTUAL_ENV=${POETRY_VIRTUALENVS_PATH} \ PATH=${POETRY_VIRTUALENVS_PATH}/bin:$PATH COPY --from=build --chown=nonroot:nonroot ${POETRY_VIRTUALENVS_PATH} ${POETRY_VIRTUALENVS_PATH} diff --git a/services/document-extractor/pyproject.toml b/services/document-extractor/pyproject.toml index 81a10f36..1c36f000 100644 --- a/services/document-extractor/pyproject.toml +++ b/services/document-extractor/pyproject.toml @@ -13,9 +13,9 @@ multiline-quotes = '"""' dictionaries = ["en_US", "python", "technical", "pandas"] ban-relative-imports = true per-file-ignores = """ - ./main.py: D100, ./tests/*: S101,D100,D103,D104, **/__init__.py: D104, + ./main.py: D100, """ [tool.black] diff --git a/services/mcp-server/Dockerfile b/services/mcp-server/Dockerfile index fd8b495c..39b3fa9a 100644 --- a/services/mcp-server/Dockerfile +++ b/services/mcp-server/Dockerfile @@ -31,18 +31,15 @@ WORKDIR /app/services/mcp-server RUN adduser --disabled-password --gecos "" --uid 10001 nonroot ENV POETRY_VIRTUALENVS_PATH=/opt/.venv +ENV POETRY_VIRTUALENVS_IN_PROJECT=false \ + VIRTUAL_ENV=/opt/.venv \ + PATH=/opt/.venv/bin:$PATH COPY --from=build --chown=nonroot:nonroot ${POETRY_VIRTUALENVS_PATH} ${POETRY_VIRTUALENVS_PATH} COPY --from=build /usr/local/bin/ /usr/local/bin/ COPY --from=build /usr/bin/make /usr/bin/make COPY --from=build /usr/local/lib/ /usr/local/lib/ -# Ensure poetry reuses existing virtualenv when running as nonroot -ENV POETRY_VIRTUALENVS_CREATE=false \ - POETRY_VIRTUALENVS_IN_PROJECT=false \ - VIRTUAL_ENV=/opt/.venv \ - PATH=/opt/.venv/bin:$PATH - COPY --from=build --chown=nonroot:nonroot /app/services/mcp-server/log /app/services/mcp-server/log COPY --chown=nonroot:nonroot services/mcp-server/src ./src COPY --chown=nonroot:nonroot services/mcp-server/tests ./tests @@ -56,6 +53,4 @@ RUN apt-get clean autoclean && apt-get autoremove --yes \ USER nonroot -COPY --from=build --chown=nonroot:nonroot /app/services/mcp-server/log /app/services/mcp-server/log - CMD [ "poetry", "run", "python", "src/main.py" ] diff --git a/services/rag-backend/Dockerfile b/services/rag-backend/Dockerfile index 70c28c7d..f4b8fb67 100644 --- a/services/rag-backend/Dockerfile +++ b/services/rag-backend/Dockerfile @@ -41,8 +41,7 @@ WORKDIR /app/services/rag-backend RUN adduser --disabled-password --gecos "" --uid 10001 nonroot ENV POETRY_VIRTUALENVS_PATH=/opt/.venv -ENV POETRY_VIRTUALENVS_CREATE=false \ - POETRY_VIRTUALENVS_IN_PROJECT=false \ +ENV POETRY_VIRTUALENVS_IN_PROJECT=false \ VIRTUAL_ENV=${POETRY_VIRTUALENVS_PATH} \ PATH=${POETRY_VIRTUALENVS_PATH}/bin:$PATH @@ -78,4 +77,3 @@ RUN apt-get clean autoclean && apt-get autoremove --yes \ && rm -rf /var/lib/{apt,dpkg,cache,log}/ || true USER nonroot -COPY --from=build --chown=nonroot:nonroot /app/services/rag-backend/log /app/services/rag-backend/log From 70d5d8826b420e312d1c19d34e2dad0187aa96e8 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 20:55:24 +0100 Subject: [PATCH 06/12] chore: refactor workflows and scripts for improved version management and clean semver enforcement --- .github/workflows/bump-chart-version.yml | 58 ++++++ .github/workflows/create-release.yml | 18 ++ .github/workflows/prepare-release.yml | 23 ++- .github/workflows/promote-clean-semver.yml | 161 ++++++++++++++++ .github/workflows/publish-chart-packages.yml | 113 +++++++++++ .github/workflows/publish-chart.yml | 95 ---------- .github/workflows/publish-libs-on-merge.yml | 149 --------------- .github/workflows/publish-pre-and-qa.yml | 190 +++++++++++++++++++ tools/blackbox/qa_smoke.sh | 61 ++++++ tools/bump_chart_versions.py | 30 ++- tools/publish_libs.sh | 41 ++++ 11 files changed, 689 insertions(+), 250 deletions(-) create mode 100644 .github/workflows/bump-chart-version.yml create mode 100644 .github/workflows/promote-clean-semver.yml create mode 100644 .github/workflows/publish-chart-packages.yml delete mode 100644 .github/workflows/publish-chart.yml delete mode 100644 .github/workflows/publish-libs-on-merge.yml create mode 100644 .github/workflows/publish-pre-and-qa.yml create mode 100755 tools/blackbox/qa_smoke.sh create mode 100755 tools/publish_libs.sh diff --git a/.github/workflows/bump-chart-version.yml b/.github/workflows/bump-chart-version.yml new file mode 100644 index 00000000..e2fa42cf --- /dev/null +++ b/.github/workflows/bump-chart-version.yml @@ -0,0 +1,58 @@ +name: bump-chart-version +on: + workflow_dispatch: + inputs: + chart_version: + description: "Chart version to set (does not touch appVersion)" + required: true + type: string + ref: + description: "Git ref to bump (default: main)" + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || 'main' }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install "pyyaml==6.0.2" "packaging==25.0" + + - name: Bump chart version only + env: + CHART_VERSION: ${{ inputs.chart_version }} + run: | + if [ -z "${CHART_VERSION}" ]; then + echo "chart_version input is required" >&2 + exit 1 + fi + python tools/bump_chart_versions.py --mode chart-only --chart-version "$CHART_VERSION" + + - name: Open PR for chart version bump + uses: peter-evans/create-pull-request@v6 + with: + base: main + branch: chore/chart-bump-${{ inputs.chart_version }} + title: "chore(release): bump chart version to ${{ inputs.chart_version }}" + body: | + Bump Chart.yaml version to ${{ inputs.chart_version }} (appVersion unchanged). + commit-message: "chore(release): bump chart version to ${{ inputs.chart_version }}" + add-paths: | + infrastructure/**/Chart.yaml + labels: chart-bump diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 864eedc1..138a6e75 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -31,6 +31,24 @@ jobs: fi echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Verify appVersion matches release version (clean semver) + env: + RELEASE_VERSION: ${{ steps.ver.outputs.version }} + run: | + if echo "$RELEASE_VERSION" | grep -q '\.post'; then + echo "Release version must be clean semver (no .post): $RELEASE_VERSION" >&2 + exit 1 + fi + APP_VERSION=$(awk '/^appVersion:/ {print $2}' infrastructure/rag/Chart.yaml | tr -d "\"'") + if [ -z "$APP_VERSION" ]; then + echo "Could not read appVersion from infrastructure/rag/Chart.yaml" >&2 + exit 1 + fi + if [ "$APP_VERSION" != "$RELEASE_VERSION" ]; then + echo "Chart appVersion ($APP_VERSION) does not match release version ($RELEASE_VERSION)" >&2 + exit 1 + fi + - name: Create Git tag run: | git config user.name "github-actions" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 7940e36c..76fdc871 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -10,7 +10,7 @@ permissions: pull-requests: write jobs: - prepare: + changes: if: >- ${{ github.event.pull_request.merged && @@ -18,7 +18,28 @@ jobs: !contains(github.event.pull_request.labels.*.name, 'refresh-locks') && !contains(github.event.pull_request.labels.*.name, 'chart-bump') }} + name: Detect release-relevant changes + runs-on: ubuntu-latest + outputs: + releasable: ${{ steps.filter.outputs.releasable }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Filter paths + id: filter + uses: dorny/paths-filter@v2 + with: + filters: | + releasable: + - 'services/**' + - 'libs/**' + + prepare: + if: ${{ needs.changes.outputs.releasable == 'true' }} runs-on: ubuntu-latest + needs: [changes] steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/promote-clean-semver.yml b/.github/workflows/promote-clean-semver.yml new file mode 100644 index 00000000..5982c2f8 --- /dev/null +++ b/.github/workflows/promote-clean-semver.yml @@ -0,0 +1,161 @@ +name: promote-clean-semver +on: + workflow_run: + workflows: ["publish-pre-and-qa"] + types: [completed] + workflow_dispatch: + inputs: + clean_version: + description: "Clean semver to promote (no .post)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + packages: write + +jobs: + promote: + name: Publish clean semver, refresh locks, bump appVersion + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || 'main' }} + + - name: Load version metadata (workflow_run) + if: ${{ github.event_name == 'workflow_run' }} + uses: actions/download-artifact@v4 + with: + name: pre-release-meta + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: meta + + - name: Determine versions + id: versions + run: | + set -euo pipefail + CLEAN_VERSION_INPUT="${{ inputs.clean_version || '' }}" + if [ -f meta/version.env ]; then + source meta/version.env + fi + CLEAN_VERSION="${CLEAN_VERSION_INPUT:-${CLEAN_VERSION:-}}" + if [ -z "${CLEAN_VERSION:-}" ]; then + echo "CLEAN_VERSION is required (input or artifact)" >&2 + exit 1 + fi + if echo "$CLEAN_VERSION" | grep -q '\.post'; then + echo "CLEAN_VERSION must be clean semver (no .post): $CLEAN_VERSION" >&2 + exit 1 + fi + echo "clean_version=$CLEAN_VERSION" >> $GITHUB_OUTPUT + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry and deps + run: | + pip install poetry==2.1.3 + python -m pip install --upgrade pip + python -m pip install "tomlkit==0.13.3" "pyyaml==6.0.2" "packaging==25.0" + + - name: Configure TestPyPI repository + run: | + poetry config repositories.testpypi https://test.pypi.org/legacy/ + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Update internal libs and service pins to clean version + env: + VERSION: ${{ steps.versions.outputs.clean_version }} + run: | + python tools/bump_pyproject_deps.py --version "$VERSION" --bump-libs --bump-service-pins + + - name: Publish clean semver to TestPyPI and PyPI (core-first) + env: + CLEAN_VERSION: ${{ steps.versions.outputs.clean_version }} + POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ + POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} + run: | + set -euo pipefail + if [ -z "${POETRY_HTTP_BASIC_TESTPYPI_PASSWORD:-}" ]; then + echo "Missing TEST_PYPI_TOKEN secret" >&2 + exit 1 + fi + if [ -z "${POETRY_PYPI_TOKEN_PYPI:-}" ]; then + echo "Missing PYPI_TOKEN secret" >&2 + exit 1 + fi + + source tools/publish_libs.sh + + echo "Publishing clean version $CLEAN_VERSION (core-first) to TestPyPI..." + publish_lib "rag-core-lib" "-r testpypi" "$CLEAN_VERSION" + wait_for_index "rag-core-lib" "$CLEAN_VERSION" "https://test.pypi.org" "TestPyPI" + for lib in rag-core-api admin-api-lib extractor-api-lib; do + publish_lib "$lib" "-r testpypi" "$CLEAN_VERSION" + done + + echo "Publishing clean version $CLEAN_VERSION (core-first) to PyPI..." + publish_lib "rag-core-lib" "" "$CLEAN_VERSION" + wait_for_index "rag-core-lib" "$CLEAN_VERSION" "https://pypi.org" "PyPI" + for lib in rag-core-api admin-api-lib extractor-api-lib; do + publish_lib "$lib" "" "$CLEAN_VERSION" + done + + - name: Clear poetry caches + run: | + poetry cache clear --all pypi -n || true + poetry cache clear --all testpypi -n || true + + - name: Refresh service lockfiles + env: + VERSION: ${{ steps.versions.outputs.clean_version }} + run: | + for svc in services/rag-backend services/admin-backend services/document-extractor services/mcp-server; do + if [ -f "$svc/pyproject.toml" ]; then + echo "Locking $svc" + ( + cd "$svc" + poetry lock -v || ( + echo "Lock failed, clearing caches and retrying..."; + poetry cache clear --all pypi -n || true; + poetry cache clear --all testpypi -n || true; + sleep 10; + poetry lock -v + ) + ) + fi + done + + - name: Bump Chart appVersion to clean version (leave chart version for manual chart publish) + env: + APP_VERSION: ${{ steps.versions.outputs.clean_version }} + run: | + python tools/bump_chart_versions.py --app-version "$APP_VERSION" --mode app-only + + - name: Open PR with updated locks, pins, and Chart appVersion + id: cpr + uses: peter-evans/create-pull-request@v6 + with: + branch: chore/refresh-locks-${{ steps.versions.outputs.clean_version }}-${{ github.run_number }} + title: "chore(release): refresh service lockfiles for ${{ steps.versions.outputs.clean_version }}" + body: | + Refresh service poetry.lock files, dependency pins, and Chart appVersion for version ${{ steps.versions.outputs.clean_version }}. + commit-message: "chore(release): refresh service lockfiles, pins, and Chart appVersion" + add-paths: | + services/**/pyproject.toml + services/**/poetry.lock + infrastructure/**/Chart.yaml + libs/**/pyproject.toml + labels: refresh-locks diff --git a/.github/workflows/publish-chart-packages.yml b/.github/workflows/publish-chart-packages.yml new file mode 100644 index 00000000..3c17e553 --- /dev/null +++ b/.github/workflows/publish-chart-packages.yml @@ -0,0 +1,113 @@ +name: publish-chart-packages +on: + pull_request: + branches: [main] + types: [closed] + workflow_dispatch: + inputs: + chart_version: + description: "Chart version to publish (default: read from Chart.yaml)" + required: false + type: string + ref: + description: "Git ref to package from (default: main)" + required: false + type: string + +permissions: + contents: write + packages: write + pages: write + id-token: write + +env: + OCI_REGISTRY: ghcr.io + +jobs: + publish: + runs-on: ubuntu-latest + if: | + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'chart-bump')) + || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || 'main' }} + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Login to GHCR for Helm OCI + run: echo ${{ secrets.GHCR_PAT }} | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Determine chart version + id: meta + run: | + set -euo pipefail + INPUT_VER="${{ inputs.chart_version }}" + FILE_VER=$(awk '/^version:/ {print $2}' infrastructure/rag/Chart.yaml | tr -d "\"'") + CHART_VERSION="${INPUT_VER:-$FILE_VER}" + if [ -z "$CHART_VERSION" ]; then + echo "Could not determine chart version" >&2 + exit 1 + fi + echo "chart_version=$CHART_VERSION" >> $GITHUB_OUTPUT + + - name: Verify chart version matches input (if provided) + env: + INPUT_VER: ${{ inputs.chart_version }} + FILE_VER: ${{ steps.meta.outputs.chart_version }} + run: | + if [ -n "$INPUT_VER" ] && [ "$INPUT_VER" != "$FILE_VER" ]; then + echo "Chart.yaml version ($FILE_VER) does not match input $INPUT_VER" >&2 + exit 1 + fi + + - name: Package chart + run: | + set -euo pipefail + CHART_DIR="infrastructure/rag" + mkdir -p dist + helm dependency update "$CHART_DIR" || true + helm package "$CHART_DIR" --destination dist + ls -la dist + + - name: Push chart to GHCR (OCI) + env: + CHART_VERSION: ${{ steps.meta.outputs.chart_version }} + run: | + set -euo pipefail + PKG=$(ls dist/*.tgz) + helm show chart "$PKG" | grep -E "^version: " + helm push "$PKG" oci://$OCI_REGISTRY/${{ github.repository_owner }}/charts + + - name: Build Helm repo index for Pages + env: + CHART_VERSION: ${{ steps.meta.outputs.chart_version }} + run: | + set -euo pipefail + PKG=$(ls dist/*.tgz) + REPO="${GITHUB_REPOSITORY#*/}" + BASE_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO}" + helm repo index dist --url "$BASE_URL" + echo "Index generated for $BASE_URL" + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy-pages: + needs: publish + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml deleted file mode 100644 index 89e62fa8..00000000 --- a/.github/workflows/publish-chart.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: publish-chart -run-name: publish-chart (post-build-images) -on: - workflow_run: - workflows: [build-images] - types: [completed] - -permissions: - contents: write - pull-requests: write - packages: write - -jobs: - chart: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Checkout release tag from triggering run - run: | - git fetch --tags --force - HEAD_SHA="${{ github.event.workflow_run.head_sha }}" - if [ -n "$HEAD_SHA" ]; then - TAG=$(git tag --points-at "$HEAD_SHA" | head -n 1 || true) - if [ -z "$TAG" ]; then - TAG=$(git describe --tags --abbrev=0 "$HEAD_SHA" 2>/dev/null || true) - fi - fi - if [ -z "$TAG" ]; then - echo "No tag found (head_sha=$HEAD_SHA)" >&2 - exit 1 - fi - git checkout "$TAG" - echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV - echo "APP_VERSION=${TAG#v}" >> $GITHUB_ENV - - - name: Expose app version - id: meta - run: echo "app_version=${APP_VERSION}" >> $GITHUB_OUTPUT - - - name: Setup Helm - uses: azure/setup-helm@v4 - - - name: Login to GHCR for Helm OCI - run: echo ${{ secrets.GHCR_PAT }} | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin - - - name: Setup Python (for bump script) - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install bump script deps - run: | - python -m pip install --upgrade pip - python -m pip install "pyyaml==6.0.2" "packaging==25.0" - - - name: Bump Chart.yaml (set release version) - env: - APP_VERSION: ${{ env.APP_VERSION }} - run: | - python tools/bump_chart_versions.py --app-version "$APP_VERSION" - - - name: Package and push rag chart - env: - APP_VERSION: ${{ env.APP_VERSION }} - run: | - set -euo pipefail - export HELM_EXPERIMENTAL_OCI=1 - CHART_DIR="infrastructure/rag" - if [ ! -f "$CHART_DIR/Chart.yaml" ]; then - echo "Expected chart at $CHART_DIR/Chart.yaml not found" >&2 - exit 1 - fi - mkdir -p dist - helm dependency update "$CHART_DIR" || true - helm package "$CHART_DIR" --destination dist - PKG=$(ls dist/*.tgz) - helm show chart "$PKG" | grep -E "^version: " - helm push "$PKG" oci://ghcr.io/${{ github.repository_owner }}/charts - - - name: Create PR for chart version bumps - uses: peter-evans/create-pull-request@v6 - with: - base: main - branch: chore/chart-bump-${{ steps.meta.outputs.app_version }} - title: "chore(release): bump chart versions to ${{ steps.meta.outputs.app_version }}" - body: | - Persist Chart.yaml appVersion/version to match release ${{ steps.meta.outputs.app_version }}. - commit-message: "chore(release): bump charts to ${{ steps.meta.outputs.app_version }}" - add-paths: | - infrastructure/**/Chart.yaml - labels: chart-bump diff --git a/.github/workflows/publish-libs-on-merge.yml b/.github/workflows/publish-libs-on-merge.yml deleted file mode 100644 index 95dec1b9..00000000 --- a/.github/workflows/publish-libs-on-merge.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: publish-libs-on-merge -on: - pull_request: - branches: [main] - types: [closed] - -permissions: - contents: write - pull-requests: write - packages: write - issues: write - -jobs: - publish: - if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'prepare-release') }} - runs-on: ubuntu-latest - outputs: - version: ${{ steps.ver.outputs.version }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Derive version from PR title - id: ver - run: | - TITLE="${{ github.event.pull_request.title }}" - VERSION=$(echo "$TITLE" | sed -nE 's/.*prepare ([0-9]+\.[0-9]+\.[0-9]+(\.post[0-9]+)?).*/\1/p' || true) - if [ -z "$VERSION" ]; then - echo "Could not derive version from PR title" >&2 - exit 1 - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install Poetry - run: | - pip install poetry==2.1.3 - - name: Configure TestPyPI repository #TODO: should be pypi later. - run: | - poetry config repositories.testpypi https://test.pypi.org/legacy/ - - name: Build and publish libs to TestPyPI #TODO: if STACKIT org is created, the gha should be authorized, no token necessary anymore! - env: - POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ - POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} - run: | - for lib in libs/*; do - [ -d "$lib" ] || continue - echo "Publishing $lib" - (cd "$lib" && poetry version "${{ steps.ver.outputs.version }}" && poetry build && poetry publish -r testpypi) - done - lock-services: - runs-on: ubuntu-latest - needs: publish - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install Poetry - run: | - pip install poetry==2.1.3 - - name: Install bump script deps - run: | - python -m pip install --upgrade pip - python -m pip install "tomlkit==0.13.3" - - name: Update service dependency pins to released version - env: - VERSION: ${{ needs.publish.outputs.version }} - run: | - python tools/bump_pyproject_deps.py --version "$VERSION" --bump-service-pins - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Wait for TestPyPI indexing - env: - VERSION: ${{ needs.publish.outputs.version }} - run: | - echo "Waiting for TestPyPI to index internal libs for version $VERSION" - for name in admin-api-lib extractor-api-lib rag-core-api rag-core-lib; do - echo "Checking $name==$VERSION" - seen=false - for i in $(seq 1 60); do # up to ~5 minutes - json_ok=false - simple_ok=false - if curl -fsSL "https://test.pypi.org/pypi/$name/json" | jq -e --arg v "$VERSION" '.releases[$v] | length > 0' >/dev/null; then - json_ok=true - fi - # Check simple index also, Poetry resolves via /simple - if curl -fsSL "https://test.pypi.org/simple/$name/" | grep -q "$VERSION"; then - simple_ok=true - fi - if [ "$json_ok" = true ] && [ "$simple_ok" = true ]; then - echo "Found $name==$VERSION on JSON and /simple" - seen=true - break - fi - sleep 5 - done - if [ "$seen" != "true" ]; then - echo "Error: $name==$VERSION not visible on TestPyPI JSON+simple yet" - echo "--- Debug /simple page for $name ---" - curl -fsSL "https://test.pypi.org/simple/$name/" || true - exit 1 - fi - done - - name: Clear poetry caches - run: | - poetry cache clear --all pypi -n || true - poetry cache clear --all testpypi -n || true - - - name: Refresh service lockfiles - run: | - for svc in services/rag-backend services/admin-backend services/document-extractor services/mcp-server; do - if [ -f "$svc/pyproject.toml" ]; then - echo "Locking $svc" - ( - cd "$svc" - poetry lock -v || ( - echo "Lock failed, clearing caches and retrying..."; - poetry cache clear --all pypi -n || true; - poetry cache clear --all testpypi -n || true; - sleep 10; - poetry lock -v - ) - ) - fi - done - - name: Open PR with updated lockfiles and pins - id: cpr - uses: peter-evans/create-pull-request@v6 - with: - branch: chore/refresh-locks-${{ needs.publish.outputs.version }}-${{ github.run_number }} - title: "chore(release): refresh service lockfiles for ${{ needs.publish.outputs.version }}" - body: | - Refresh service poetry.lock files and dependency pins for version ${{ needs.publish.outputs.version }}. - commit-message: "chore(release): refresh service lockfiles and pins" - add-paths: | - services/**/pyproject.toml - services/**/poetry.lock - labels: refresh-locks diff --git a/.github/workflows/publish-pre-and-qa.yml b/.github/workflows/publish-pre-and-qa.yml new file mode 100644 index 00000000..eec4cdcd --- /dev/null +++ b/.github/workflows/publish-pre-and-qa.yml @@ -0,0 +1,190 @@ +name: publish-pre-and-qa +on: + pull_request: + branches: [main] + types: [closed] + +permissions: + contents: write + pull-requests: write + packages: write + issues: write + +jobs: + publish-pre: + name: Publish pre-release to TestPyPI (core-first) + if: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'prepare-release') }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + clean_version: ${{ steps.ver.outputs.clean_version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Derive version from PR title + id: ver + run: | + set -euo pipefail + TITLE="${{ github.event.pull_request.title }}" + VERSION=$(echo "$TITLE" | sed -nE 's/.*prepare ([0-9]+\.[0-9]+\.[0-9]+(\.post[0-9]+)?).*/\1/p' || true) + if [ -z "$VERSION" ]; then + echo "Could not derive version from PR title: $TITLE" >&2 + exit 1 + fi + CLEAN_VERSION="${VERSION%%.post*}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "clean_version=$CLEAN_VERSION" >> $GITHUB_OUTPUT + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + run: | + pip install poetry==2.1.3 + + - name: Configure TestPyPI repository + run: | + poetry config repositories.testpypi https://test.pypi.org/legacy/ + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Publish rag-core-lib first (TestPyPI) and wait for index + env: + VERSION: ${{ steps.ver.outputs.version }} + POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ + POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} + run: | + set -euo pipefail + if [ -z "${POETRY_HTTP_BASIC_TESTPYPI_PASSWORD:-}" ]; then + echo "Missing TEST_PYPI_TOKEN secret" >&2 + exit 1 + fi + source tools/publish_libs.sh + publish_lib "rag-core-lib" "-r testpypi" "$VERSION" + wait_for_index "rag-core-lib" "$VERSION" "https://test.pypi.org" "TestPyPI" + + - name: Publish remaining libs to TestPyPI + env: + VERSION: ${{ steps.ver.outputs.version }} + POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ + POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} + run: | + set -euo pipefail + source tools/publish_libs.sh + for lib in rag-core-api admin-api-lib extractor-api-lib; do + path="libs/$lib" + [ -d "$path" ] || { echo "Missing $path" >&2; exit 1; } + publish_lib "$lib" "-r testpypi" "$VERSION" "$path" + done + + - name: Persist version metadata for downstream workflows + env: + VERSION: ${{ steps.ver.outputs.version }} + CLEAN_VERSION: ${{ steps.ver.outputs.clean_version }} + run: | + set -euo pipefail + mkdir -p meta + { + echo "PRE_VERSION=${VERSION}" + echo "CLEAN_VERSION=${CLEAN_VERSION}" + } > meta/version.env + # Upload artifact for workflow_run trigger consumption + - name: Upload version artifact + uses: actions/upload-artifact@v4 + with: + name: pre-release-meta + path: meta/version.env + retention-days: 5 + + qa-verify: + name: Deploy to QA and run black-box tests + runs-on: ubuntu-latest + needs: publish-pre + if: ${{ needs.publish-pre.result == 'success' }} + env: + PRE_RELEASE_VERSION: ${{ needs.publish-pre.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate QA secrets are present + run: | + missing=() + [ -z "${{ secrets.STACKIT_KUBECONFIG }}" ] && missing+=("STACKIT_KUBECONFIG") + [ -z "${{ secrets.QA_NAMESPACE }}" ] && missing+=("QA_NAMESPACE") + [ -z "${{ secrets.QA_BASE_URL }}" ] && missing+=("QA_BASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "Missing required secrets: ${missing[*]}" >&2 + exit 1 + fi + + - name: Install kubectl + uses: azure/setup-kubectl@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + + - name: Configure kubeconfig + run: | + mkdir -p "$HOME/.kube" + echo "${{ secrets.STACKIT_KUBECONFIG }}" > "$HOME/.kube/config" + chmod 600 "$HOME/.kube/config" + + - name: Deploy to QA via Helm + env: + QA_NAMESPACE: ${{ secrets.QA_NAMESPACE }} + QA_HELM_VALUES_FILE: ${{ secrets.QA_HELM_VALUES_FILE }} + RELEASE_NAME: rag-qa + run: | + set -euo pipefail + VALUES_ARG="" + if [ -n "${QA_HELM_VALUES_FILE:-}" ]; then + echo "Using custom values file from QA_HELM_VALUES_FILE" + printf '%s' "${QA_HELM_VALUES_FILE}" > /tmp/qa-values.yaml + VALUES_ARG="-f /tmp/qa-values.yaml" + elif [ -f infrastructure/rag/values.yaml ]; then + echo "Using default chart values from infrastructure/rag/values.yaml" + VALUES_ARG="-f infrastructure/rag/values.yaml" + else + echo "No values file provided and default missing" >&2 + exit 1 + fi + + helm dependency update infrastructure/rag || true + helm upgrade --install "$RELEASE_NAME" infrastructure/rag \ + --namespace "$QA_NAMESPACE" \ + --create-namespace \ + $VALUES_ARG + + - name: Wait for core workloads to be ready + env: + QA_NAMESPACE: ${{ secrets.QA_NAMESPACE }} + run: | + set -euo pipefail + for deploy in backend admin-backend extractor frontend admin-frontend; do + if kubectl -n "$QA_NAMESPACE" get deployment "$deploy" >/dev/null 2>&1; then + echo "Waiting for deployment/$deploy rollout..." + kubectl -n "$QA_NAMESPACE" rollout status "deployment/$deploy" --timeout=5m + else + echo "deployment/$deploy not found in namespace $QA_NAMESPACE (skipping)" + fi + done + + - name: Run black-box smoke tests + env: + QA_BASE_URL: ${{ secrets.QA_BASE_URL }} + QA_ADMIN_TOKEN: ${{ secrets.QA_ADMIN_TOKEN }} + QA_DOC_PATHS: ${{ secrets.QA_DOC_PATHS }} + QA_DOC_UPLOAD_URL: ${{ secrets.QA_DOC_UPLOAD_URL }} + QA_CHAT_URL: ${{ secrets.QA_CHAT_URL }} + QA_EXPECTED_ANSWER: ${{ secrets.QA_EXPECTED_ANSWER }} + QA_QUESTION: ${{ secrets.QA_QUESTION }} + run: | + set -euo pipefail + bash tools/blackbox/qa_smoke.sh diff --git a/tools/blackbox/qa_smoke.sh b/tools/blackbox/qa_smoke.sh new file mode 100755 index 00000000..7d0195b9 --- /dev/null +++ b/tools/blackbox/qa_smoke.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Minimal black-box smoke harness. +# Required env: +# QA_BASE_URL - base URL of the QA deployment (e.g. https://qa.example.com) +# Optional env: +# QA_DOC_UPLOAD_URL - full URL to upload documents (multipart/form-data) +# QA_DOC_PATHS - whitespace-separated local file paths to upload (if empty, upload is skipped) +# QA_CHAT_URL - full URL to ask a question (expects JSON POST) +# QA_QUESTION - question to ask (default: "What is 2+2?") +# QA_EXPECTED_ANSWER - substring expected in the chat response (if set, assert) +# QA_ADMIN_TOKEN - bearer token added to requests (optional) + +if [ -z "${QA_BASE_URL:-}" ]; then + echo "QA_BASE_URL must be set for black-box smoke tests." >&2 + exit 1 +fi + +AUTH_HEADER=() +if [ -n "${QA_ADMIN_TOKEN:-}" ]; then + AUTH_HEADER=(-H "Authorization: Bearer ${QA_ADMIN_TOKEN}") +fi + +# Optional document upload +if [ -n "${QA_DOC_UPLOAD_URL:-}" ] && [ -n "${QA_DOC_PATHS:-}" ]; then + echo "Uploading documents to ${QA_DOC_UPLOAD_URL}" + for doc in ${QA_DOC_PATHS}; do + if [ ! -f "$doc" ]; then + echo "Document not found: $doc" >&2 + exit 1 + fi + curl -fsS "${AUTH_HEADER[@]}" \ + -F "file=@${doc}" \ + "$QA_DOC_UPLOAD_URL" >/dev/null + echo "Uploaded $doc" + done +else + echo "Skipping document upload (QA_DOC_UPLOAD_URL or QA_DOC_PATHS not set)" +fi + +# Optional question/answer check +if [ -n "${QA_CHAT_URL:-}" ]; then + QUESTION="${QA_QUESTION:-What is 2+2?}" + echo "Asking question against ${QA_CHAT_URL}" + RESPONSE=$(curl -fsS "${AUTH_HEADER[@]}" \ + -H "Content-Type: application/json" \ + -d "{\"question\": \"${QUESTION}\"}" \ + "$QA_CHAT_URL") + echo "Response: $RESPONSE" + if [ -n "${QA_EXPECTED_ANSWER:-}" ]; then + echo "$RESPONSE" | grep -qi --fixed-strings "${QA_EXPECTED_ANSWER}" || { + echo "Expected answer fragment not found: ${QA_EXPECTED_ANSWER}" >&2 + exit 1 + } + fi +else + echo "Skipping Q&A check (QA_CHAT_URL not set)" +fi + +echo "Black-box smoke tests completed." diff --git a/tools/bump_chart_versions.py b/tools/bump_chart_versions.py index 9c4da5a0..d349da86 100644 --- a/tools/bump_chart_versions.py +++ b/tools/bump_chart_versions.py @@ -33,21 +33,41 @@ def _to_chart_version(app_version: str) -> str: return base.group(1) if base else app_version -def bump_chart(chart_path: pathlib.Path, app_version: str): +def bump_chart(chart_path: pathlib.Path, app_version: str, mode: str): data = yaml.safe_load(chart_path.read_text()) - data['appVersion'] = str(app_version) - data['version'] = _to_chart_version(str(app_version)) + if mode in ("app-and-chart", "app-only"): + if not app_version: + raise ValueError("app_version is required for mode app-and-chart or app-only") + data['appVersion'] = str(app_version) + if mode in ("app-and-chart", "chart-only"): + if mode == "chart-only": + if not app_version: + raise ValueError("chart-only mode requires chart_version provided via app_version argument") + data['version'] = str(app_version) + else: + data['version'] = _to_chart_version(str(app_version)) chart_path.write_text(yaml.safe_dump(data, sort_keys=False)) def main(): p = argparse.ArgumentParser() - p.add_argument('--app-version', required=True) + p.add_argument('--app-version', help='App version to set (required for app-and-chart/app-only)') + p.add_argument('--chart-version', help='Chart version to set (required for chart-only)') + p.add_argument( + '--mode', + choices=['app-and-chart', 'app-only', 'chart-only'], + default='app-and-chart', + help='app-and-chart: bump appVersion and chart version; app-only: bump only appVersion; chart-only: bump only chart version' + ) args = p.parse_args() + app_version = args.app_version + if args.mode == 'chart-only': + app_version = args.chart_version + charts = list((ROOT / 'infrastructure').glob('*/Chart.yaml')) for ch in charts: - bump_chart(ch, args.app_version) + bump_chart(ch, app_version, args.mode) if __name__ == '__main__': diff --git a/tools/publish_libs.sh b/tools/publish_libs.sh new file mode 100755 index 00000000..cfd6ecc7 --- /dev/null +++ b/tools/publish_libs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Shared helpers for publishing internal libs and waiting for index visibility. +set -euo pipefail + +publish_lib() { + local lib="$1" + local repo_flag="$2" # "" or "-r testpypi" + local version="$3" + local path="${4:-libs/$lib}" + if [ ! -d "$path" ]; then + echo "Missing $path" >&2 + exit 1 + fi + echo "Publishing $lib ($version) to ${repo_flag:-pypi default}" + (cd "$path" && poetry version "$version" && poetry build && poetry publish $repo_flag) +} + +wait_for_index() { + local name="$1" + local version="$2" + local base_url="$3" + local label="$4" + echo "Waiting for $name==$version on $label" + for _ in $(seq 1 60); do + json_ok=false + simple_ok=false + if curl -fsSL "$base_url/pypi/$name/json" | jq -e --arg v "$version" '.releases[$v] | length > 0' >/dev/null; then + json_ok=true + fi + if curl -fsSL "$base_url/simple/$name/" | grep -q "$version"; then + simple_ok=true + fi + if [ "$json_ok" = true ] && [ "$simple_ok" = true ]; then + echo "$name==$version visible on $label" + return 0 + fi + sleep 5 + done + echo "$name==$version not visible on $label after waiting" >&2 + return 1 +} From 439ea442e7b8bc5277a480fc3ec207c7c1e35b8e Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Tue, 25 Nov 2025 21:17:30 +0100 Subject: [PATCH 07/12] chore: enhance promote-clean-semver workflow with dry run option and refactoring for better publish control --- .github/workflows/promote-clean-semver.yml | 32 +++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/promote-clean-semver.yml b/.github/workflows/promote-clean-semver.yml index 5982c2f8..0beb3d40 100644 --- a/.github/workflows/promote-clean-semver.yml +++ b/.github/workflows/promote-clean-semver.yml @@ -9,6 +9,19 @@ on: description: "Clean semver to promote (no .post)" required: true type: string + ref: + description: "Git ref to use (defaults to main)" + required: false + type: string + pre_version: + description: "Pre-release version (optional, for bookkeeping)" + required: false + type: string + dry_run: + description: "If true, skip publishing to PyPI (only TestPyPI)" + required: false + default: false + type: boolean permissions: contents: write @@ -86,13 +99,14 @@ jobs: POETRY_HTTP_BASIC_TESTPYPI_USERNAME: __token__ POETRY_HTTP_BASIC_TESTPYPI_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }} run: | set -euo pipefail if [ -z "${POETRY_HTTP_BASIC_TESTPYPI_PASSWORD:-}" ]; then echo "Missing TEST_PYPI_TOKEN secret" >&2 exit 1 fi - if [ -z "${POETRY_PYPI_TOKEN_PYPI:-}" ]; then + if [ "${DRY_RUN:-false}" != "true" ] && [ -z "${POETRY_PYPI_TOKEN_PYPI:-}" ]; then echo "Missing PYPI_TOKEN secret" >&2 exit 1 fi @@ -106,12 +120,16 @@ jobs: publish_lib "$lib" "-r testpypi" "$CLEAN_VERSION" done - echo "Publishing clean version $CLEAN_VERSION (core-first) to PyPI..." - publish_lib "rag-core-lib" "" "$CLEAN_VERSION" - wait_for_index "rag-core-lib" "$CLEAN_VERSION" "https://pypi.org" "PyPI" - for lib in rag-core-api admin-api-lib extractor-api-lib; do - publish_lib "$lib" "" "$CLEAN_VERSION" - done + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run enabled: skipping PyPI publish." + else + echo "Publishing clean version $CLEAN_VERSION (core-first) to PyPI..." + publish_lib "rag-core-lib" "" "$CLEAN_VERSION" + wait_for_index "rag-core-lib" "$CLEAN_VERSION" "https://pypi.org" "PyPI" + for lib in rag-core-api admin-api-lib extractor-api-lib; do + publish_lib "$lib" "" "$CLEAN_VERSION" + done + fi - name: Clear poetry caches run: | From d6dcc27d0fd8b91616ea67b67ed5029adc452816 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Wed, 26 Nov 2025 09:37:16 +0100 Subject: [PATCH 08/12] chore: add peer dependencies to package-lock.json for improved compatibility --- package-lock.json | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/package-lock.json b/package-lock.json index dec6b938..f62eadbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -2468,6 +2469,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -2983,6 +2985,34 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm/node_modules/@npmcli/arborist/node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist/node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm/node_modules/@npmcli/config": { "version": "10.4.3", "dev": true, @@ -3002,6 +3032,34 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm/node_modules/@npmcli/config/node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config/node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm/node_modules/@npmcli/fs": { "version": "4.0.0", "dev": true, @@ -3920,6 +3978,49 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm/node_modules/libnpmdiff/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff/node_modules/npm-bundled": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm/node_modules/libnpmexec": { "version": "10.1.9", "dev": true, @@ -4902,6 +5003,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5578,6 +5680,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -6433,6 +6536,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, From d26876ae237ad60b0b8d9af3dfbdbf4be139845a Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Wed, 26 Nov 2025 09:43:29 +0100 Subject: [PATCH 09/12] chore: update Node.js version to 25 in prepare-release workflow --- .github/workflows/prepare-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 76fdc871..c59208ae 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '25' - name: Install semantic-release deps run: npm ci From f2a5d7f03f21425ac0cc93d1805a894eac206288 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Wed, 26 Nov 2025 09:59:30 +0100 Subject: [PATCH 10/12] chore: update workflows to use PR_AUTOMATION_TOKEN for improved security and consistency --- .github/workflows/bump-chart-version.yml | 1 + .github/workflows/create-release.yml | 3 ++- .github/workflows/prepare-release.yml | 3 ++- .github/workflows/promote-clean-semver.yml | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump-chart-version.yml b/.github/workflows/bump-chart-version.yml index e2fa42cf..8a238309 100644 --- a/.github/workflows/bump-chart-version.yml +++ b/.github/workflows/bump-chart-version.yml @@ -55,4 +55,5 @@ jobs: commit-message: "chore(release): bump chart version to ${{ inputs.chart_version }}" add-paths: | infrastructure/**/Chart.yaml + token: ${{ secrets.PR_AUTOMATION_TOKEN }} labels: chart-bump diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 138a6e75..caf971d5 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -19,6 +19,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.PR_AUTOMATION_TOKEN }} - name: Derive version from PR title id: ver @@ -62,4 +63,4 @@ jobs: tag_name: v${{ steps.ver.outputs.version }} name: v${{ steps.ver.outputs.version }} generate_release_notes: true - token: ${{ secrets.GHCR_PAT }} + token: ${{ secrets.PR_AUTOMATION_TOKEN }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index c59208ae..ea157328 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -59,7 +59,7 @@ jobs: - name: Compute next version (dry-run) id: semrel env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.PR_AUTOMATION_TOKEN }} run: | npx semantic-release --dry-run --no-ci | tee semrel.log BASE_VERSION=$(grep -Eo "next release version is [0-9]+\.[0-9]+\.[0-9]+" semrel.log | awk '{print $5}') @@ -93,3 +93,4 @@ jobs: add-paths: | libs/**/pyproject.toml labels: prepare-release + token: ${{ secrets.PR_AUTOMATION_TOKEN }} diff --git a/.github/workflows/promote-clean-semver.yml b/.github/workflows/promote-clean-semver.yml index 0beb3d40..bb488269 100644 --- a/.github/workflows/promote-clean-semver.yml +++ b/.github/workflows/promote-clean-semver.yml @@ -177,3 +177,4 @@ jobs: infrastructure/**/Chart.yaml libs/**/pyproject.toml labels: refresh-locks + token: ${{ secrets.PR_AUTOMATION_TOKEN }} From 6c13c00e2f29649f554c011dc0bbdc8f0ab58694 Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Wed, 26 Nov 2025 10:11:54 +0100 Subject: [PATCH 11/12] chore: add workflow_dispatch inputs for clean version promotion and dry run option --- .github/workflows/publish-pre-and-qa.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/publish-pre-and-qa.yml b/.github/workflows/publish-pre-and-qa.yml index eec4cdcd..91ee4600 100644 --- a/.github/workflows/publish-pre-and-qa.yml +++ b/.github/workflows/publish-pre-and-qa.yml @@ -3,6 +3,25 @@ on: pull_request: branches: [main] types: [closed] + workflow_dispatch: + inputs: + clean_version: + description: "Clean semver to promote (no .post)" + required: true + type: string + ref: + description: "Git ref to use (defaults to main)" + required: false + type: string + pre_version: + description: "Pre-release version (optional, for bookkeeping)" + required: false + type: string + dry_run: + description: "If true, skip publishing to PyPI (only TestPyPI)" + required: false + default: false + type: boolean permissions: contents: write From ceb9703de5cb9dd9f5196e567996f1c87f51a94f Mon Sep 17 00:00:00 2001 From: Andreas Klos Date: Thu, 27 Nov 2025 14:10:13 +0100 Subject: [PATCH 12/12] chore: refactor QA workflow and update deployment values handling --- .github/workflows/promote-clean-semver.yml | 6 - .github/workflows/publish-pre-and-qa.yml | 128 ++++++++++-------- .../rag/templates/backend/service.yaml | 4 + infrastructure/rag/values.yaml | 1 + tools/blackbox/qa_smoke.sh | 61 --------- tools/update_deploy_values.py | 62 +++++++++ 6 files changed, 142 insertions(+), 120 deletions(-) delete mode 100755 tools/blackbox/qa_smoke.sh create mode 100644 tools/update_deploy_values.py diff --git a/.github/workflows/promote-clean-semver.yml b/.github/workflows/promote-clean-semver.yml index bb488269..d32049a5 100644 --- a/.github/workflows/promote-clean-semver.yml +++ b/.github/workflows/promote-clean-semver.yml @@ -1,8 +1,5 @@ name: promote-clean-semver on: - workflow_run: - workflows: ["publish-pre-and-qa"] - types: [completed] workflow_dispatch: inputs: clean_version: @@ -31,9 +28,6 @@ permissions: jobs: promote: name: Publish clean semver, refresh locks, bump appVersion - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-pre-and-qa.yml b/.github/workflows/publish-pre-and-qa.yml index 91ee4600..ce33f4a1 100644 --- a/.github/workflows/publish-pre-and-qa.yml +++ b/.github/workflows/publish-pre-and-qa.yml @@ -121,7 +121,7 @@ jobs: retention-days: 5 qa-verify: - name: Deploy to QA and run black-box tests + name: Build QA images and update Argo deployment repo runs-on: ubuntu-latest needs: publish-pre if: ${{ needs.publish-pre.result == 'success' }} @@ -135,75 +135,97 @@ jobs: - name: Validate QA secrets are present run: | missing=() - [ -z "${{ secrets.STACKIT_KUBECONFIG }}" ] && missing+=("STACKIT_KUBECONFIG") - [ -z "${{ secrets.QA_NAMESPACE }}" ] && missing+=("QA_NAMESPACE") - [ -z "${{ secrets.QA_BASE_URL }}" ] && missing+=("QA_BASE_URL") + [ -z "${{ secrets.STACKIT_REGISTRY_USERNAME }}" ] && missing+=("STACKIT_REGISTRY_USERNAME") + [ -z "${{ secrets.STACKIT_REGISTRY_PASSWORD }}" ] && missing+=("STACKIT_REGISTRY_PASSWORD") + [ -z "${{ secrets.DEPLOY_REPO_URL }}" ] && missing+=("DEPLOY_REPO_URL") + [ -z "${{ secrets.DEPLOY_REPO_TOKEN }}" ] && missing+=("DEPLOY_REPO_TOKEN") + [ -z "${{ secrets.QA_IMAGE_REGISTRY }}" ] && echo "QA_IMAGE_REGISTRY not set, defaulting to registry.onstackit.cloud/qa-rag-template" if [ ${#missing[@]} -gt 0 ]; then echo "Missing required secrets: ${missing[*]}" >&2 exit 1 fi - - name: Install kubectl - uses: azure/setup-kubectl@v4 + - name: Set image registry and version + env: + QA_IMAGE_REGISTRY: ${{ secrets.QA_IMAGE_REGISTRY }} + run: | + IMAGE_REGISTRY="${QA_IMAGE_REGISTRY:-registry.onstackit.cloud/qa-rag-template}" + echo "IMAGE_REGISTRY=${IMAGE_REGISTRY}" >> "$GITHUB_ENV" + echo "IMAGE_TAG=${PRE_RELEASE_VERSION}" >> "$GITHUB_ENV" - - name: Install Helm - uses: azure/setup-helm@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Configure kubeconfig + - name: Login to STACKIT registry + env: + STACKIT_REGISTRY_USERNAME: ${{ secrets.STACKIT_REGISTRY_USERNAME }} + STACKIT_REGISTRY_PASSWORD: ${{ secrets.STACKIT_REGISTRY_PASSWORD }} run: | - mkdir -p "$HOME/.kube" - echo "${{ secrets.STACKIT_KUBECONFIG }}" > "$HOME/.kube/config" - chmod 600 "$HOME/.kube/config" + echo "$STACKIT_REGISTRY_PASSWORD" | docker login registry.onstackit.cloud -u "$STACKIT_REGISTRY_USERNAME" --password-stdin - - name: Deploy to QA via Helm + - name: Build and push QA images env: - QA_NAMESPACE: ${{ secrets.QA_NAMESPACE }} - QA_HELM_VALUES_FILE: ${{ secrets.QA_HELM_VALUES_FILE }} - RELEASE_NAME: rag-qa + IMAGE_REGISTRY: ${{ env.IMAGE_REGISTRY }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} run: | set -euo pipefail - VALUES_ARG="" - if [ -n "${QA_HELM_VALUES_FILE:-}" ]; then - echo "Using custom values file from QA_HELM_VALUES_FILE" - printf '%s' "${QA_HELM_VALUES_FILE}" > /tmp/qa-values.yaml - VALUES_ARG="-f /tmp/qa-values.yaml" - elif [ -f infrastructure/rag/values.yaml ]; then - echo "Using default chart values from infrastructure/rag/values.yaml" - VALUES_ARG="-f infrastructure/rag/values.yaml" - else - echo "No values file provided and default missing" >&2 - exit 1 - fi - - helm dependency update infrastructure/rag || true - helm upgrade --install "$RELEASE_NAME" infrastructure/rag \ - --namespace "$QA_NAMESPACE" \ - --create-namespace \ - $VALUES_ARG + images=( + "rag-backend services/rag-backend/Dockerfile ." + "admin-backend services/admin-backend/Dockerfile ." + "document-extractor services/document-extractor/Dockerfile ." + "mcp-server services/mcp-server/Dockerfile ." + "frontend services/frontend/apps/chat-app/Dockerfile services/frontend" + "admin-frontend services/frontend/apps/admin-app/Dockerfile services/frontend" + ) + for entry in "${images[@]}"; do + IFS=" " read -r name dockerfile context <<<"$entry" + ref="$IMAGE_REGISTRY/$name:$IMAGE_TAG" + echo "Building and pushing $ref (Dockerfile=$dockerfile context=$context)" + docker buildx build --platform linux/amd64 -t "$ref" -f "$dockerfile" "$context" --push + done - - name: Wait for core workloads to be ready + - name: Checkout deployment repo env: - QA_NAMESPACE: ${{ secrets.QA_NAMESPACE }} + DEPLOY_REPO_URL: ${{ secrets.DEPLOY_REPO_URL }} + DEPLOY_REPO_BRANCH: ${{ secrets.DEPLOY_REPO_BRANCH || 'main' }} + DEPLOY_REPO_TOKEN: ${{ secrets.DEPLOY_REPO_TOKEN }} run: | set -euo pipefail - for deploy in backend admin-backend extractor frontend admin-frontend; do - if kubectl -n "$QA_NAMESPACE" get deployment "$deploy" >/dev/null 2>&1; then - echo "Waiting for deployment/$deploy rollout..." - kubectl -n "$QA_NAMESPACE" rollout status "deployment/$deploy" --timeout=5m - else - echo "deployment/$deploy not found in namespace $QA_NAMESPACE (skipping)" - fi - done + mkdir -p /tmp/deploy-repo + AUTH_URL="${DEPLOY_REPO_URL/https:\/\//https://${DEPLOY_REPO_TOKEN}@}" + git clone --depth 1 --branch "${DEPLOY_REPO_BRANCH:-main}" "$AUTH_URL" /tmp/deploy-repo - - name: Run black-box smoke tests + - name: Update values file in deployment repo + env: + IMAGE_REGISTRY: ${{ env.IMAGE_REGISTRY }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + DEPLOY_VALUES_FILE: ${{ secrets.DEPLOY_VALUES_FILE || 'values-qa.yaml' }} + run: | + set -euo pipefail + cd /tmp/deploy-repo + python -m pip install --quiet pyyaml + python "$GITHUB_WORKSPACE/tools/update_deploy_values.py" \ + --values-file "$DEPLOY_VALUES_FILE" \ + --image-registry "$IMAGE_REGISTRY" \ + --image-tag "$IMAGE_TAG" + + - name: Commit and push deployment repo changes env: - QA_BASE_URL: ${{ secrets.QA_BASE_URL }} - QA_ADMIN_TOKEN: ${{ secrets.QA_ADMIN_TOKEN }} - QA_DOC_PATHS: ${{ secrets.QA_DOC_PATHS }} - QA_DOC_UPLOAD_URL: ${{ secrets.QA_DOC_UPLOAD_URL }} - QA_CHAT_URL: ${{ secrets.QA_CHAT_URL }} - QA_EXPECTED_ANSWER: ${{ secrets.QA_EXPECTED_ANSWER }} - QA_QUESTION: ${{ secrets.QA_QUESTION }} + DEPLOY_REPO_BRANCH: ${{ secrets.DEPLOY_REPO_BRANCH || 'main' }} + DEPLOY_REPO_TOKEN: ${{ secrets.DEPLOY_REPO_TOKEN }} + DEPLOY_REPO_URL: ${{ secrets.DEPLOY_REPO_URL }} + DEPLOY_GIT_USER_NAME: ${{ secrets.DEPLOY_GIT_USER_NAME || 'github-actions' }} + DEPLOY_GIT_USER_EMAIL: ${{ secrets.DEPLOY_GIT_USER_EMAIL || 'github-actions@users.noreply.github.com' }} run: | set -euo pipefail - bash tools/blackbox/qa_smoke.sh + cd /tmp/deploy-repo + git config user.name "${DEPLOY_GIT_USER_NAME}" + git config user.email "${DEPLOY_GIT_USER_EMAIL}" + git add . + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "chore: update QA images to ${IMAGE_TAG}" + AUTH_URL="${DEPLOY_REPO_URL/https:\/\//https://${DEPLOY_REPO_TOKEN}@}" + git push "$AUTH_URL" "HEAD:${DEPLOY_REPO_BRANCH:-main}" diff --git a/infrastructure/rag/templates/backend/service.yaml b/infrastructure/rag/templates/backend/service.yaml index 847b5c7c..4c98e05e 100644 --- a/infrastructure/rag/templates/backend/service.yaml +++ b/infrastructure/rag/templates/backend/service.yaml @@ -2,6 +2,10 @@ apiVersion: v1 kind: Service metadata: name: {{ .Values.backend.name }} + {{- with .Values.backend.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: {{ .Values.backend.service.type }} ports: diff --git a/infrastructure/rag/values.yaml b/infrastructure/rag/values.yaml index 8c759336..c8d95a29 100644 --- a/infrastructure/rag/values.yaml +++ b/infrastructure/rag/values.yaml @@ -131,6 +131,7 @@ backend: service: type: ClusterIP port: 8080 + annotations: {} pythonPathEnv: PYTHONPATH: src diff --git a/tools/blackbox/qa_smoke.sh b/tools/blackbox/qa_smoke.sh deleted file mode 100755 index 7d0195b9..00000000 --- a/tools/blackbox/qa_smoke.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Minimal black-box smoke harness. -# Required env: -# QA_BASE_URL - base URL of the QA deployment (e.g. https://qa.example.com) -# Optional env: -# QA_DOC_UPLOAD_URL - full URL to upload documents (multipart/form-data) -# QA_DOC_PATHS - whitespace-separated local file paths to upload (if empty, upload is skipped) -# QA_CHAT_URL - full URL to ask a question (expects JSON POST) -# QA_QUESTION - question to ask (default: "What is 2+2?") -# QA_EXPECTED_ANSWER - substring expected in the chat response (if set, assert) -# QA_ADMIN_TOKEN - bearer token added to requests (optional) - -if [ -z "${QA_BASE_URL:-}" ]; then - echo "QA_BASE_URL must be set for black-box smoke tests." >&2 - exit 1 -fi - -AUTH_HEADER=() -if [ -n "${QA_ADMIN_TOKEN:-}" ]; then - AUTH_HEADER=(-H "Authorization: Bearer ${QA_ADMIN_TOKEN}") -fi - -# Optional document upload -if [ -n "${QA_DOC_UPLOAD_URL:-}" ] && [ -n "${QA_DOC_PATHS:-}" ]; then - echo "Uploading documents to ${QA_DOC_UPLOAD_URL}" - for doc in ${QA_DOC_PATHS}; do - if [ ! -f "$doc" ]; then - echo "Document not found: $doc" >&2 - exit 1 - fi - curl -fsS "${AUTH_HEADER[@]}" \ - -F "file=@${doc}" \ - "$QA_DOC_UPLOAD_URL" >/dev/null - echo "Uploaded $doc" - done -else - echo "Skipping document upload (QA_DOC_UPLOAD_URL or QA_DOC_PATHS not set)" -fi - -# Optional question/answer check -if [ -n "${QA_CHAT_URL:-}" ]; then - QUESTION="${QA_QUESTION:-What is 2+2?}" - echo "Asking question against ${QA_CHAT_URL}" - RESPONSE=$(curl -fsS "${AUTH_HEADER[@]}" \ - -H "Content-Type: application/json" \ - -d "{\"question\": \"${QUESTION}\"}" \ - "$QA_CHAT_URL") - echo "Response: $RESPONSE" - if [ -n "${QA_EXPECTED_ANSWER:-}" ]; then - echo "$RESPONSE" | grep -qi --fixed-strings "${QA_EXPECTED_ANSWER}" || { - echo "Expected answer fragment not found: ${QA_EXPECTED_ANSWER}" >&2 - exit 1 - } - fi -else - echo "Skipping Q&A check (QA_CHAT_URL not set)" -fi - -echo "Black-box smoke tests completed." diff --git a/tools/update_deploy_values.py b/tools/update_deploy_values.py new file mode 100644 index 00000000..4f460830 --- /dev/null +++ b/tools/update_deploy_values.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Update deployment repo values file with image registry/tag overrides.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any, Dict + +import yaml + + +def ensure(mapping: Dict[str, Any], key: str) -> Dict[str, Any]: + """Ensure key exists and is a dict.""" + if key not in mapping or mapping[key] is None: + mapping[key] = {} + if not isinstance(mapping[key], dict): + raise TypeError(f"Expected dict at {key}, got {type(mapping[key])}") + return mapping[key] + + +def update_values(values_path: Path, image_registry: str, image_tag: str) -> None: + if values_path.exists(): + data = yaml.safe_load(values_path.read_text(encoding="utf-8")) or {} + else: + data = {} + + components = { + "backend": "rag-backend", + "adminBackend": "admin-backend", + "extractor": "document-extractor", + "frontend": "frontend", + "adminFrontend": "admin-frontend", + } + + for key, image_name in components.items(): + comp = ensure(data, key) + image_block = ensure(comp, "image") + image_block["repository"] = f"{image_registry}/{image_name}" + image_block["tag"] = image_tag + + backend = ensure(data, "backend") + mcp = ensure(backend, "mcp") + mcp_image = ensure(mcp, "image") + mcp_image["repository"] = f"{image_registry}/mcp-server" + mcp_image["tag"] = image_tag + + values_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Update image overrides in a values file.") + parser.add_argument("--values-file", required=True, help="Path to values-qa.yaml in deployment repo") + parser.add_argument("--image-registry", required=True, help="Image registry base (e.g. registry.onstackit.cloud/qa-rag-template)") + parser.add_argument("--image-tag", required=True, help="Image tag/version to set") + args = parser.parse_args() + + update_values(Path(args.values_file), args.image_registry, args.image_tag) + + +if __name__ == "__main__": + main()