diff --git a/.github/workflows/docker-build-v2.yml b/.github/workflows/docker-build-v2.yml index 68d24a4a36..92c7e8f099 100644 --- a/.github/workflows/docker-build-v2.yml +++ b/.github/workflows/docker-build-v2.yml @@ -16,6 +16,11 @@ on: required: true type: string description: "Ref to check out (branch, tag, or commit). This is required -- it specifies where the source code for the release is located." + version_override: + required: false + type: string + default: "" + description: "Resolved version to use for image tags. When omitted, the workflow computes the version from the checked-out ref and registry history." push_to_registry: required: false type: boolean @@ -44,6 +49,11 @@ on: required: true type: string description: "Ref to check out (branch, tag, or commit). This is required -- it specifies where the source code for the release is located." + version_override: + description: "Resolved version to use for image tags. Leave blank to compute from the checked-out ref and registry history." + required: false + type: string + default: "" push_to_registry: description: "Whether to push images to registries. Set to false for testing builds without publishing." required: false @@ -81,10 +91,16 @@ jobs: version=$(uv tree 2>/dev/null | grep '^langflow-base' | cut -d' ' -f2 | sed 's/^v//') echo "Base version from pyproject.toml: $version" - if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-.*\.rc[0-9]+' | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest base pre-release version: $last_released_version" + if [ -n "${{ inputs.version_override }}" ]; then + version="${{ inputs.version_override }}" + echo "Using version override: $version" + if [ ${{inputs.pre_release}} != "true" ]; then + last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sed 's/^base-//' | sort -V | tail -n 1) + echo "Latest base release version: $last_released_version" + fi + elif [ ${{inputs.pre_release}} == "true" ]; then + released_versions=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' || true) + version="$(printf '%s\n' "$released_versions" | uv run ./scripts/ci/langflow_pre_release_tag.py "$version" -)" echo "Base pre-release version to be released: $version" else last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sed 's/^base-//' | sort -V | tail -n 1) @@ -127,10 +143,16 @@ jobs: version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//') echo "Main version from pyproject.toml: $version" - if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -E '\.rc[0-9]+' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest main pre-release version: $last_released_version" + if [ -n "${{ inputs.version_override }}" ]; then + version="${{ inputs.version_override }}" + echo "Using version override: $version" + if [ ${{inputs.pre_release}} != "true" ]; then + last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sort -V | tail -n 1) + echo "Latest main release version: $last_released_version" + fi + elif [ ${{inputs.pre_release}} == "true" ]; then + released_versions=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' || true) + version="$(printf '%s\n' "$released_versions" | uv run ./scripts/ci/langflow_pre_release_tag.py "$version" -)" echo "Main pre-release version to be released: $version" else last_released_version=$(curl -s "https://registry.hub.docker.com/v2/repositories/langflowai/langflow/tags?page_size=100" | jq -r '.results[].name' | grep -v '^base-' | grep -vE '\-(amd64|arm64)$' | grep -v 'latest' | sort -V | tail -n 1) @@ -335,7 +357,7 @@ jobs: context: . push: ${{ inputs.push_to_registry }} build-args: | - LANGFLOW_IMAGE=langflowai/langflow:${{ steps.version.outputs.version }}-${{ matrix.arch }} + LANGFLOW_IMAGE=langflowai/langflow:${{ needs.determine-main-version.outputs.version }}-${{ matrix.arch }} file: ./docker/build_and_push_backend.Dockerfile tags: ${{ steps.tags.outputs.docker_tags }} platforms: linux/${{ matrix.arch }} @@ -356,7 +378,7 @@ jobs: context: . push: ${{ inputs.push_to_registry }} build-args: | - LANGFLOW_IMAGE=ghcr.io/langflow-ai/langflow:${{ steps.version.outputs.version }}-${{ matrix.arch }} + LANGFLOW_IMAGE=ghcr.io/langflow-ai/langflow:${{ needs.determine-main-version.outputs.version }}-${{ matrix.arch }} file: ./docker/build_and_push_backend.Dockerfile tags: ${{ steps.tags.outputs.ghcr_tags }} platforms: linux/${{ matrix.arch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ade6471a4c..63c6ef8961 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,9 +177,160 @@ jobs: echo "✅ Release dependencies validated successfully." + determine-rc-number: + name: Determine Shared RC Number + needs: [validate-tag, validate-dependencies] + runs-on: ubuntu-latest + outputs: + rc_number: ${{ steps.rc.outputs.rc_number }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_tag }} + + - name: Setup Environment + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.13" + prune-cache: false + + - name: Determine shared pre-release RC number + id: rc + shell: bash + run: | + set -euo pipefail + + if [ "${{ inputs.pre_release }}" != "true" ]; then + echo "rc_number=0" >> "$GITHUB_OUTPUT" + echo "Not a pre-release; shared rc number is 0." + exit 0 + fi + + read_pyproject_version() { + python3 - "$1" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["version"]) + PY + } + + fetch_pypi_versions() { + local package_name="$1" + local output_file="$2" + local response_file + response_file="$(mktemp)" + local status + status=$(curl --retry 3 --retry-delay 2 --retry-connrefused -sS -o "$response_file" -w "%{http_code}" "https://pypi.org/pypi/${package_name}/json") + if [ "$status" = "404" ]; then + : > "$output_file" + return + fi + if [ "$status" != "200" ]; then + echo "Failed to fetch PyPI releases for ${package_name}: HTTP ${status}" >&2 + cat "$response_file" >&2 || true + exit 1 + fi + jq -r '.releases | keys[]' "$response_file" > "$output_file" + } + + fetch_docker_tags() { + local repository="$1" + local output_file="$2" + local response_file + response_file="$(mktemp)" + local status + status=$(curl --retry 3 --retry-delay 2 --retry-connrefused -sS -o "$response_file" -w "%{http_code}" "https://registry.hub.docker.com/v2/repositories/${repository}/tags?page_size=100") + if [ "$status" != "200" ]; then + echo "Failed to fetch Docker tags for ${repository}: HTTP ${status}" >&2 + cat "$response_file" >&2 || true + exit 1 + fi + jq -r '.results[].name' "$response_file" > "$output_file" + } + + max_rc=0 + consider_versions() { + local label="$1" + local base_version="$2" + local versions_file="$3" + local rc_number + rc_number=$(uv run ./scripts/ci/langflow_pre_release_tag.py "$base_version" --print-rc-number - < "$versions_file") + echo "${label}: next rc number for ${base_version} is ${rc_number}" + if [ "$rc_number" -gt "$max_rc" ]; then + max_rc="$rc_number" + fi + } + + tmp_dir="$(mktemp -d)" + + if [ "${{ inputs.release_package_main }}" = "true" ]; then + version="$(read_pyproject_version pyproject.toml)" + fetch_pypi_versions "langflow" "$tmp_dir/langflow-pypi.txt" + consider_versions "PyPI langflow" "$version" "$tmp_dir/langflow-pypi.txt" + fi + + if [ "${{ inputs.release_package_base }}" = "true" ]; then + version="$(read_pyproject_version src/backend/base/pyproject.toml)" + fetch_pypi_versions "langflow-base" "$tmp_dir/langflow-base-pypi.txt" + consider_versions "PyPI langflow-base" "$version" "$tmp_dir/langflow-base-pypi.txt" + fi + + if [ "${{ inputs.release_lfx }}" = "true" ]; then + version="$(read_pyproject_version src/lfx/pyproject.toml)" + fetch_pypi_versions "lfx" "$tmp_dir/lfx-pypi.txt" + consider_versions "PyPI lfx" "$version" "$tmp_dir/lfx-pypi.txt" + fi + + if [ "${{ inputs.release_sdk || inputs.release_lfx }}" = "true" ]; then + version="$(read_pyproject_version src/sdk/pyproject.toml)" + fetch_pypi_versions "langflow-sdk" "$tmp_dir/langflow-sdk-pypi.txt" + consider_versions "PyPI langflow-sdk" "$version" "$tmp_dir/langflow-sdk-pypi.txt" + fi + + if [ "${{ inputs.release_bundles }}" = "true" ]; then + for pyproject in src/bundles/*/pyproject.toml; do + package_name=$(python3 - "$pyproject" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["name"]) + PY + ) + version="$(read_pyproject_version "$pyproject")" + output_file="$tmp_dir/${package_name}-pypi.txt" + fetch_pypi_versions "$package_name" "$output_file" + consider_versions "PyPI ${package_name}" "$version" "$output_file" + done + fi + + if [ "${{ inputs.build_docker_main }}" = "true" ]; then + version="$(read_pyproject_version pyproject.toml)" + fetch_docker_tags "langflowai/langflow" "$tmp_dir/langflow-docker.txt" + grep -v '^base-' "$tmp_dir/langflow-docker.txt" | grep -vE '\-(amd64|arm64)$' > "$tmp_dir/langflow-docker-release-tags.txt" || true + consider_versions "Docker langflowai/langflow" "$version" "$tmp_dir/langflow-docker-release-tags.txt" + fi + + if [ "${{ inputs.build_docker_base }}" = "true" ]; then + version="$(read_pyproject_version src/backend/base/pyproject.toml)" + fetch_docker_tags "langflowai/langflow" "$tmp_dir/langflow-base-docker.txt" + grep -E '^base-' "$tmp_dir/langflow-base-docker.txt" | grep -vE '\-(amd64|arm64)$' | sed 's/^base-//' > "$tmp_dir/langflow-base-docker-release-tags.txt" || true + consider_versions "Docker langflowai/langflow base" "$version" "$tmp_dir/langflow-base-docker-release-tags.txt" + fi + + echo "Shared pre-release rc number: ${max_rc}" + echo "rc_number=${max_rc}" >> "$GITHUB_OUTPUT" + determine-base-version: name: Determine Base Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -212,16 +363,16 @@ jobs: echo "Base version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow-base/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest base pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "Base pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow-base/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) echo "Latest base release version: $last_released_version" fi - if [ "$version" = "$last_released_version" ]; then + if [ "$version" = "$last_released_version" ] && [ "${{ inputs.release_package_base }}" = "true" ]; then echo "Base pypi version $version is already released. Skipping release." echo skipped=true >> $GITHUB_OUTPUT exit 1 @@ -232,7 +383,7 @@ jobs: determine-main-version: name: Determine Main Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -261,16 +412,16 @@ jobs: echo "Main version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest main pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "Main pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) echo "Latest main release version: $last_released_version" fi - if [ "$version" = "$last_released_version" ]; then + if [ "$version" = "$last_released_version" ] && [ "${{ inputs.release_package_main }}" = "true" ]; then echo "Main pypi version $version is already released. Skipping release." echo skipped=true >> $GITHUB_OUTPUT exit 1 @@ -281,7 +432,7 @@ jobs: determine-lfx-version: name: Determine LFX Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -310,9 +461,9 @@ jobs: echo "LFX version from pyproject.toml: $version" if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/lfx/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest LFX pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "LFX pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/lfx/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) @@ -330,7 +481,7 @@ jobs: determine-sdk-version: name: Determine SDK Version - needs: [validate-tag, validate-dependencies] + needs: [validate-tag, validate-dependencies, determine-rc-number] if: ${{ inputs.release_sdk || inputs.release_lfx }} runs-on: ubuntu-latest outputs: @@ -368,11 +519,17 @@ jobs: if [ "$pypi_response" = "404" ]; then echo "langflow-sdk has never been published to PyPI. This will be the first release." last_released_version="" + if [ ${{inputs.pre_release}} == "true" ]; then + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" + echo "SDK pre-release version to be released: $version" + fi else if [ ${{inputs.pre_release}} == "true" ]; then - last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) - version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")" - echo "Latest SDK pre-release version: $last_released_version" + rc_number="${{ needs.determine-rc-number.outputs.rc_number }}" + version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" --rc-number "$rc_number")" + echo "Shared release candidate number: rc$rc_number" echo "SDK pre-release version to be released: $version" else last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1) @@ -862,7 +1019,7 @@ jobs: build-bundles: name: Build Langflow Bundles - needs: [build-lfx, determine-lfx-version] + needs: [build-lfx, determine-lfx-version, determine-rc-number] if: | always() && !cancelled() && @@ -888,6 +1045,26 @@ jobs: prune-cache: false - name: Install the project run: uv sync + - name: Set bundle versions for pre-release + if: ${{ inputs.pre_release }} + run: | + RC_NUMBER="${{ needs.determine-rc-number.outputs.rc_number }}" + echo "Setting bundle pre-release versions with shared rc number: rc$RC_NUMBER" + for pyproject in src/bundles/*/pyproject.toml; do + CURRENT_VERSION=$(python3 - "$pyproject" <<'PY' + import pathlib + import sys + import tomllib + + data = tomllib.loads(pathlib.Path(sys.argv[1]).read_text()) + print(data["project"]["version"]) + PY + ) + VERSION=$(uv run ./scripts/ci/langflow_pre_release_tag.py "$CURRENT_VERSION" --rc-number "$RC_NUMBER") + sed -i.bak "0,/^version = /s|^version = .*|version = \"$VERSION\"|" "$pyproject" + rm -f "${pyproject}.bak" + grep "^version" "$pyproject" | sed "s|^| ${pyproject}: |" + done - name: Relax bundle lfx floor for pre-release if: ${{ inputs.pre_release }} run: | @@ -1129,72 +1306,78 @@ jobs: call_docker_build_base: name: Call Docker Build Workflow for Langflow Base if: ${{ inputs.build_docker_base }} - needs: [ci] + needs: [ci, determine-base-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: base pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-base-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main: name: Call Docker Build Workflow for Langflow if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_backend: name: Call Docker Build Workflow for Langflow Backend if: ${{ inputs.build_docker_main && !inputs.dry_run }} - needs: [call_docker_build_main] + needs: [call_docker_build_main, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-backend pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_frontend: name: Call Docker Build Workflow for Langflow Frontend if: ${{ inputs.build_docker_main && !inputs.dry_run }} - needs: [call_docker_build_main] + needs: [call_docker_build_main, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-frontend pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_ep: name: Call Docker Build Workflow for Langflow with Entrypoint if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-ep pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit call_docker_build_main_all: name: Call Docker Build Workflow for langflow-all if: ${{ inputs.build_docker_main }} - needs: [ci] + needs: [ci, determine-main-version] uses: ./.github/workflows/docker-build-v2.yml with: ref: ${{ inputs.release_tag }} release_type: main-all pre_release: ${{ inputs.pre_release }} + version_override: ${{ needs.determine-main-version.outputs.version }} push_to_registry: ${{ !inputs.dry_run }} secrets: inherit diff --git a/.secrets.baseline b/.secrets.baseline index 845cba3325..ad9d20eda8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -143,7 +143,7 @@ "filename": ".github/workflows/release.yml", "hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0", "is_verified": false, - "line_number": 403, + "line_number": 560, "is_secret": false } ], @@ -9287,5 +9287,5 @@ } ] }, - "generated_at": "2026-06-22T20:34:55Z" + "generated_at": "2026-06-22T22:13:44Z" } diff --git a/scripts/ci/langflow_pre_release_tag.py b/scripts/ci/langflow_pre_release_tag.py index a070cb200b..e47534e4e8 100755 --- a/scripts/ci/langflow_pre_release_tag.py +++ b/scripts/ci/langflow_pre_release_tag.py @@ -1,39 +1,102 @@ #!/usr/bin/env python3 -import re +from __future__ import annotations + +import argparse import sys +from typing import TYPE_CHECKING -ARGUMENT_NUMBER = 3 +import packaging.version +from packaging.version import Version + +if TYPE_CHECKING: + from collections.abc import Iterable -def create_tag(package_version: str, latest_released_version: str | None) -> str: - # normalize optional leading 'v' and whitespace - pkg = package_version.strip().lstrip("v") - latest = None - if latest_released_version is not None: - lr = latest_released_version.strip() - if lr != "": - latest = lr.lstrip("v") +def _read_released_versions(values: list[str]) -> list[str]: + if values == ["-"]: + return [line.strip() for line in sys.stdin if line.strip()] + return [value.strip() for value in values if value.strip()] - new_pre_release_version = f"{pkg}.rc0" - if latest: - # match either exact pkg or pkg.rcN (with or without dot before rc, per PEP 440 normalization) - m = re.match(rf"^{re.escape(pkg)}\.?rc(\d+)$", latest) - if m: - rc_number = int(m.group(1)) + 1 - new_pre_release_version = f"{pkg}.rc{rc_number}" - elif latest == pkg: - new_pre_release_version = f"{pkg}.rc1" +def next_rc_number(package_version: str, released_versions: Iterable[str]) -> int: + base_version = Version(package_version.strip().lstrip("v")).base_version + rc_numbers: list[int] = [] + exact_final_exists = False - return new_pre_release_version + for released_version_str in released_versions: + normalized_version_str = released_version_str.strip().lstrip("v") + if not normalized_version_str: + continue + try: + released_version = Version(normalized_version_str) + except packaging.version.InvalidVersion: + continue + + if released_version.base_version != base_version: + continue + + if released_version.pre and released_version.pre[0] == "rc": + rc_numbers.append(released_version.pre[1]) + elif not released_version.is_prerelease: + exact_final_exists = True + + if rc_numbers: + return max(rc_numbers) + 1 + if exact_final_exists: + return 1 + return 0 + + +def create_tag( + package_version: str, + released_versions: Iterable[str] | str | None, + rc_number: int | None = None, +) -> str: + if released_versions is None: + versions: list[str] = [] + elif isinstance(released_versions, str): + versions = [released_versions] + else: + versions = list(released_versions) + + base_version = Version(package_version.strip().lstrip("v")).base_version + next_rc = next_rc_number(base_version, versions) + if rc_number is not None: + next_rc = max(next_rc, rc_number) + + return str(Version(f"{base_version}rc{next_rc}")) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Return the next rc pre-release for a package base version.", + ) + parser.add_argument("package_version") + parser.add_argument( + "released_versions", + nargs="*", + help="Released versions to inspect, or '-' to read one version per line from stdin.", + ) + parser.add_argument( + "--rc-number", + type=int, + default=None, + help="Minimum rc number to use. This lets a workflow apply one shared rc number across packages.", + ) + parser.add_argument( + "--print-rc-number", + action="store_true", + help="Print only the next rc number instead of the full version.", + ) + args = parser.parse_args() + + released_versions = _read_released_versions(args.released_versions) + if args.print_rc_number: + print(next_rc_number(args.package_version, released_versions)) + else: + print(create_tag(args.package_version, released_versions, args.rc_number)) if __name__ == "__main__": - if len(sys.argv) != ARGUMENT_NUMBER: - msg = "Specify package_version and latest_released_version (use empty string for none)." - raise ValueError(msg) - - package_version = sys.argv[1] - latest_released_version = sys.argv[2] - print(create_tag(package_version, latest_released_version)) + main() diff --git a/scripts/ci/test_langflow_pre_release_tag.py b/scripts/ci/test_langflow_pre_release_tag.py new file mode 100644 index 0000000000..eae3ef391f --- /dev/null +++ b/scripts/ci/test_langflow_pre_release_tag.py @@ -0,0 +1,73 @@ +"""Unit tests for langflow_pre_release_tag.py. + +Run directly or with pytest: + + cd scripts/ci && uv run python test_langflow_pre_release_tag.py + cd scripts/ci && uv run python -m pytest test_langflow_pre_release_tag.py -q +""" + +import contextlib +import io +import sys +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent)) +import langflow_pre_release_tag as pr + + +def test_ignores_unrelated_newer_pre_release_line(): + assert pr.create_tag("1.10.1", ["1.11.0.dev15"]) == "1.10.1rc0" + + +def test_increments_same_base_rc_with_or_without_dot(): + releases = ["1.10.1rc0", "1.10.1.rc1", "1.11.0.dev15"] + assert pr.create_tag("1.10.1", releases) == "1.10.1rc2" + + +def test_final_same_base_starts_at_rc1(): + assert pr.create_tag("1.10.1", ["1.10.1"]) == "1.10.1rc1" + + +def test_shared_rc_number_floor_applies_to_other_package_base(): + assert pr.create_tag("0.10.1", [], rc_number=1) == "0.10.1rc1" + + +def test_shared_rc_number_floor_does_not_regress_existing_higher_rc(): + assert pr.create_tag("1.10.1", ["1.10.1rc5"], rc_number=1) == "1.10.1rc6" + + +def test_cli_reads_released_versions_from_stdin(): + output = io.StringIO() + with ( + mock.patch.object(sys, "argv", ["langflow_pre_release_tag.py", "1.10.1", "--print-rc-number", "-"]), + mock.patch.object(sys, "stdin", io.StringIO("1.10.1rc0\n1.11.0.dev15\n")), + contextlib.redirect_stdout(output), + ): + pr.main() + assert output.getvalue().strip() == "1" + + +def test_unparseable_versions_are_skipped(): + assert pr.create_tag("1.10.1", ["not-a-version", "1.10.1rc0"]) == "1.10.1rc1" + + +def _main(): + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"FAIL {name}: {exc}") + else: + print(f"ok {name}") + if failures: + msg = f"{failures} test(s) failed." + raise SystemExit(msg) + print("All langflow_pre_release_tag tests passed.") + + +if __name__ == "__main__": + _main()