diff --git a/.github/workflows/ci-scripts-test.yml b/.github/workflows/ci-scripts-test.yml new file mode 100644 index 0000000000..c8526db2d2 --- /dev/null +++ b/.github/workflows/ci-scripts-test.yml @@ -0,0 +1,27 @@ +name: CI Scripts Tests + +on: + pull_request: + paths: + - "scripts/ci/**" + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + name: Test CI release scripts + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install pytest requests packaging + + - name: Run CI script tests + run: python -m pytest scripts/ci/ -v diff --git a/.github/workflows/nightly_build.yml b/.github/workflows/nightly_build.yml index 24af71004c..903b2975a4 100644 --- a/.github/workflows/nightly_build.yml +++ b/.github/workflows/nightly_build.yml @@ -82,7 +82,7 @@ jobs: # Required to create tag contents: write outputs: - release_tag: ${{ steps.generate_release_tag.outputs.release_tag }} + release_tag: ${{ steps.generate_shared_tag.outputs.release_tag }} base_tag: ${{ steps.set_base_tag.outputs.base_tag }} lfx_tag: ${{ steps.generate_lfx_tag.outputs.lfx_tag }} sdk_tag: ${{ steps.generate_sdk_tag.outputs.sdk_tag }} @@ -107,30 +107,32 @@ jobs: run: | echo "PWD: $(pwd)" find . -name "pypi_nightly_tag.py" - - name: Generate main nightly tag - id: generate_release_tag + - name: Generate shared nightly tag (main == base, lockstep) + id: generate_shared_tag run: | + # langflow-nightly pins langflow-base-nightly[complete]==, so both tags + # MUST be identical. Compute them in ONE invocation (single PyPI snapshot) so they + # cannot drift across separate calls. # NOTE: This outputs the tag with the `v` prefix. - RELEASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py main)" + TAGS_OUTPUT="$(uv run ./scripts/ci/pypi_nightly_tag.py both)" + RELEASE_TAG="$(printf '%s\n' "$TAGS_OUTPUT" | sed -n '1p')" + BASE_TAG="$(printf '%s\n' "$TAGS_OUTPUT" | sed -n '2p')" + if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" != "$BASE_TAG" ]; then + echo "Shared tag mismatch: release='$RELEASE_TAG' base='$BASE_TAG'. Exiting the workflow." + exit 1 + fi echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT - echo "release_tag=$RELEASE_TAG" + echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT + echo "release_tag=$RELEASE_TAG base_tag=$BASE_TAG" - name: Delete existing tag if it exists id: check_release_tag run: | git fetch --tags - git tag -d ${{ steps.generate_release_tag.outputs.release_tag }} || true - git push --delete origin ${{ steps.generate_release_tag.outputs.release_tag }} || true + git tag -d ${{ steps.generate_shared_tag.outputs.release_tag }} || true + git push --delete origin ${{ steps.generate_shared_tag.outputs.release_tag }} || true echo "release_tag_exists=false" >> $GITHUB_OUTPUT - - name: Generate base nightly tag - id: generate_base_tag - run: | - # NOTE: This outputs the tag with the `v` prefix. - BASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py base)" - echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT - echo "base_tag=$BASE_TAG" - - name: Generate LFX nightly tag id: generate_lfx_tag run: | @@ -155,8 +157,8 @@ jobs: git config --global user.email "bot-nightly-builds@langflow.org" git config --global user.name "Langflow Bot" - RELEASE_TAG="${{ steps.generate_release_tag.outputs.release_tag }}" - BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}" + RELEASE_TAG="${{ steps.generate_shared_tag.outputs.release_tag }}" + BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}" LFX_TAG="${{ steps.generate_lfx_tag.outputs.lfx_tag }}" SDK_TAG="${{ steps.generate_sdk_tag.outputs.sdk_tag }}" echo "Updating SDK project version to $SDK_TAG" @@ -195,7 +197,7 @@ jobs: - name: Checkout main nightly tag uses: actions/checkout@v6 with: - ref: ${{ steps.generate_release_tag.outputs.release_tag }} + ref: ${{ steps.generate_shared_tag.outputs.release_tag }} persist-credentials: true - name: Retrieve Base Tag @@ -214,8 +216,8 @@ jobs: BASE_TAG="${{ steps.retrieve_base_tag.outputs.base_tag }}" echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT echo "base_tag=$BASE_TAG" - elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_base_tag.outputs.base_tag }}" ]; then - BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}" + elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_shared_tag.outputs.base_tag }}" ]; then + BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}" echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT echo "base_tag=$BASE_TAG" else diff --git a/.secrets.baseline b/.secrets.baseline index 736bde6eb4..9d10e71bdb 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -187,7 +187,7 @@ "filename": ".github/workflows/nightly_build.yml", "hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0", "is_verified": false, - "line_number": 303, + "line_number": 305, "is_secret": false } ], @@ -8272,5 +8272,5 @@ } ] }, - "generated_at": "2026-05-26T14:34:58Z" + "generated_at": "2026-05-29T18:18:04Z" } diff --git a/pyproject.toml b/pyproject.toml index b7631342db..f806fe81f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -271,6 +271,13 @@ external = ["RUF027"] "TRY003", # Long exception messages "EM101", # String literals in exceptions ] +"scripts/ci/test_*.py" = [ + "D1", # Missing docstrings + "PLR2004", # Magic value comparisons + "S101", # Use of assert (required for pytest) + "TRY003", # Long exception messages + "EM101", # String literals in exceptions +] "src/backend/base/langflow/alembic/versions/*" = ["INP001", "D415", "PGH003"] "src/backend/base/langflow/custom/__init__.py" = [ "I001", # Import order affects initialization - must import custom module first diff --git a/scripts/ci/pypi_nightly_tag.py b/scripts/ci/pypi_nightly_tag.py index 3bbc4bd9c6..0c8c206433 100755 --- a/scripts/ci/pypi_nightly_tag.py +++ b/scripts/ci/pypi_nightly_tag.py @@ -1,100 +1,122 @@ #!/usr/bin/env python -"""Idea from https://github.com/streamlit/streamlit/blob/4841cf91f1c820a392441092390c4c04907f9944/scripts/pypi_nightly_create_tag.py.""" +"""Idea from https://github.com/streamlit/streamlit/blob/4841cf91f1c820a392441092390c4c04907f9944/scripts/pypi_nightly_create_tag.py. + +`langflow-nightly` pins an EXACT dependency on `langflow-base-nightly[complete]==X.Y.Z.devN`. +For the latest published `langflow-nightly` to be installable, the base version it pins must +exist on PyPI. The two packages are therefore versioned in lockstep: they share a single dev +number so that, in a single nightly run (publish order base -> main, gated), main's `devN` pin +always references the base `devN` built and published in the same run. + +The shared dev number is `max(dev across BOTH packages' PyPI histories) + 1`, restricted to +releases whose base_version matches the root pyproject. Both "main" and "base" build types +return the identical tag; the "both" mode emits it twice so the workflow can read the release +and base tags from a single invocation (one PyPI snapshot) and avoid any cross-call drift. +""" import sys +from pathlib import Path import packaging.version import requests from packaging.version import Version -PYPI_LANGFLOW_URL = "https://pypi.org/pypi/langflow/json" PYPI_LANGFLOW_NIGHTLY_URL = "https://pypi.org/pypi/langflow-nightly/json" - -PYPI_LANGFLOW_BASE_URL = "https://pypi.org/pypi/langflow-base/json" PYPI_LANGFLOW_BASE_NIGHTLY_URL = "https://pypi.org/pypi/langflow-base-nightly/json" +# main and base MUST share one dev number, so the shared number is derived from both packages. +PYPI_NIGHTLY_URLS = (PYPI_LANGFLOW_NIGHTLY_URL, PYPI_LANGFLOW_BASE_NIGHTLY_URL) + ARGUMENT_NUMBER = 2 +VALID_BUILD_TYPES = ("main", "base", "both") -def get_latest_published_version(build_type: str, *, is_nightly: bool) -> Version: - url = "" - if build_type == "base": - url = PYPI_LANGFLOW_BASE_NIGHTLY_URL if is_nightly else PYPI_LANGFLOW_BASE_URL - elif build_type == "main": - url = PYPI_LANGFLOW_NIGHTLY_URL if is_nightly else PYPI_LANGFLOW_URL - else: - msg = f"Invalid build type: {build_type}" - raise ValueError(msg) - - res = requests.get(url, timeout=10) - res.raise_for_status() - try: - version_str = res.json()["info"]["version"] - except Exception as e: - msg = "Got unexpected response from PyPI" - raise RuntimeError(msg) from e - return Version(version_str) - - -def create_tag(build_type: str): - from pathlib import Path +def _root_base_version() -> str: + """Return the base_version (e.g. "1.10.0") from the root pyproject.toml. + Both langflow-nightly and langflow-base-nightly are versioned from the ROOT pyproject on + purpose. Do not switch base to read src/backend/base/pyproject.toml, or the two dev counters + will fork again and the exact `==` pin can reference a version that was never published. + """ import tomllib - # Read version from pyproject.toml - main_tag_pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - pyproject_data = tomllib.loads(main_tag_pyproject_path.read_text()) + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + pyproject_data = tomllib.loads(pyproject_path.read_text()) + return Version(pyproject_data["project"]["version"]).base_version - current_version_str = pyproject_data["project"]["version"] - current_version = Version(current_version_str) +def _all_dev_numbers(url: str, base_version: str) -> list[int]: + """Dev numbers of every release of url whose base_version matches base_version. + + A 404 means the package genuinely has no releases yet (e.g. the first-ever nightly): it + contributes nothing and returns an empty list. Every OTHER failure -- a network error, a + non-404 HTTP status (5xx / 403 / ...), or a malformed 200 response -- is fatal and raises, + so the nightly job aborts BEFORE mutating tags. Failing closed prevents a transient lookup + failure on the higher-versioned package from lowering max(dev) + 1 and regenerating an + already-published version. Non-dev/final releases and releases from another base_version + never contribute. + """ + res = requests.get(url, timeout=10) + if res.status_code == requests.codes.not_found: + return [] + res.raise_for_status() try: - current_nightly_version = get_latest_published_version(build_type, is_nightly=True) - except (requests.RequestException, KeyError, ValueError): - # If nightly doesn't exist yet - current_nightly_version = None + releases = res.json()["releases"] + except (ValueError, KeyError) as e: + msg = f"Unexpected response from {url!r}: missing 'releases' mapping" + raise RuntimeError(msg) from e - build_number = "0" - latest_base_version = current_version.base_version - nightly_base_version = current_nightly_version.base_version if current_nightly_version else None + dev_numbers: list[int] = [] + for version_str in releases: + try: + version = Version(version_str) + except packaging.version.InvalidVersion: + continue + if version.base_version == base_version and version.dev is not None: + dev_numbers.append(version.dev) + return dev_numbers - if latest_base_version == nightly_base_version: - # If the latest version is the same as the nightly version, increment the build number - dev_number = (current_nightly_version.dev or 0) if current_nightly_version else 0 - build_number = str(dev_number + 1) - new_nightly_version = latest_base_version + ".dev" + build_number +def _shared_nightly_version() -> str: + """Compute the single dev number shared by langflow-nightly and langflow-base-nightly.""" + base_version = _root_base_version() - # Prepend "v" to the version, if DNE. - # This is an update to the nightly version format. - if not new_nightly_version.startswith("v"): - new_nightly_version = "v" + new_nightly_version + dev_numbers = [dev for url in PYPI_NIGHTLY_URLS for dev in _all_dev_numbers(url, base_version)] - # X.Y.Z.dev.YYYYMMDD - # This takes the base version of the current version and appends the - # current date. If the last release was on the same day, we exit, as - # pypi does not allow for overwriting the same version. + # First-ever nightly for this base_version -> dev0. Otherwise max+1, so the result is + # strictly ahead of BOTH packages' newest same-series dev release. + next_dev = max(dev_numbers) + 1 if dev_numbers else 0 - # We could use a different versioning scheme, such as just incrementing - # an integer. - # version_with_date = ( - # ".".join([str(x) for x in current_version.release]) - # + ".dev" - # + "0" - # + datetime.now(pytz.timezone("UTC")).strftime("%Y%m%d") - # ) + new_nightly_version = f"v{base_version}.dev{next_dev}" - # Verify if version is PEP440 compliant. + # Verify the version is PEP 440 compliant. packaging.version.Version(new_nightly_version) return new_nightly_version +def create_tag(build_type: str) -> str: + """Return the shared nightly tag (with a leading ``v``). + + ``build_type`` is accepted for backward compatibility and validated, but "main" and "base" + always return the identical version by design (lockstep versioning). + """ + if build_type not in VALID_BUILD_TYPES: + msg = f"Invalid build type: {build_type}" + raise ValueError(msg) + return _shared_nightly_version() + + if __name__ == "__main__": if len(sys.argv) != ARGUMENT_NUMBER: - msg = "Specify base or main" + msg = "Specify base, main, or both" raise ValueError(msg) - build_type = sys.argv[1] - tag = create_tag(build_type) - print(tag) + requested_build_type = sys.argv[1] + tag = create_tag(requested_build_type) + if requested_build_type == "both": + # Emit twice so the workflow can capture release_tag and base_tag from a SINGLE + # invocation -> one PyPI snapshot -> guaranteed-identical tags. + print(tag) + print(tag) + else: + print(tag) diff --git a/scripts/ci/test_pypi_nightly_tag.py b/scripts/ci/test_pypi_nightly_tag.py new file mode 100644 index 0000000000..093dfd980b --- /dev/null +++ b/scripts/ci/test_pypi_nightly_tag.py @@ -0,0 +1,192 @@ +"""Unit tests for pypi_nightly_tag.py (lockstep nightly versioning). + +Run directly (matches test_constraint_preservation.py style) or via pytest: + + cd scripts/ci && uv run python -m pytest test_pypi_nightly_tag.py -q + cd scripts/ci && uv run python test_pypi_nightly_tag.py + +All PyPI HTTP traffic is mocked; no network access. `_root_base_version` is patched so the +tests do not depend on the repo's live version. +""" + +import sys +from pathlib import Path +from unittest import mock + +import pytest +import requests + +# Ensure the sibling module is importable regardless of how pytest is invoked. +sys.path.insert(0, str(Path(__file__).parent)) +import pypi_nightly_tag as nt + +MAIN_URL = nt.PYPI_LANGFLOW_NIGHTLY_URL +BASE_URL = nt.PYPI_LANGFLOW_BASE_NIGHTLY_URL + + +class _FakeResponse: + """Minimal stand-in for a requests.Response from PyPI's /pypi//json endpoint.""" + + def __init__(self, *, releases=None, status_code=200, malformed=False): + self._releases = list(releases) if releases else [] + self.status_code = status_code + self._malformed = malformed + + def raise_for_status(self): + if self.status_code >= 400 and self.status_code != requests.codes.not_found: + msg = f"status {self.status_code}" + raise requests.HTTPError(msg) + + def json(self): + if self._malformed: + return {"info": {"version": "1.10.0.dev1"}} # 200 OK but missing "releases" + # Real PyPI shape: {"releases": {"": [], ...}}. + return {"releases": {version: [] for version in self._releases}} + + +def _build_response(spec): + """Turn a per-package spec into a _FakeResponse. + + spec: iterable of version strings, an int HTTP status (e.g. 404), the string "malformed", + or None (absent package -> empty release list). + """ + if spec is None: + return _FakeResponse(releases=[]) + if isinstance(spec, int): + return _FakeResponse(status_code=spec) + if spec == "malformed": + return _FakeResponse(malformed=True) + return _FakeResponse(releases=spec) + + +def _run(main=None, base=None, *, base_version="1.10.0", build_type="both"): + """Compute a tag with mocked PyPI responses for the main and base nightly packages.""" + responses = {MAIN_URL: _build_response(main), BASE_URL: _build_response(base)} + + def fake_get(url, timeout=10): # noqa: ARG001 + return responses[url] + + with ( + mock.patch.object(nt.requests, "get", side_effect=fake_get), + mock.patch.object(nt, "_root_base_version", return_value=base_version), + ): + return nt.create_tag(build_type) + + +# --- core invariant: main and base always resolve to the identical shared version ------------- + + +def test_main_and_base_return_identical_version_given_offset_histories(): + # Live-state analog: langflow-nightly latest dev54, langflow-base-nightly latest dev48. + main_hist = [f"1.10.0.dev{n}" for n in range(55)] + base_hist = [f"1.10.0.dev{n}" for n in range(49)] + main_tag = _run(main_hist, base_hist, build_type="main") + base_tag = _run(main_hist, base_hist, build_type="base") + both_tag = _run(main_hist, base_hist, build_type="both") + assert main_tag == base_tag == both_tag == "v1.10.0.dev55" # max(54, 48) + 1 + + +def test_aligned_histories_increment_by_one(): + hist = [f"1.10.0.dev{n}" for n in range(43)] # both latest dev42 + assert _run(hist, hist) == "v1.10.0.dev43" + + +# --- edge cases ------------------------------------------------------------------------------- + + +def test_first_ever_nightly_both_404(): + assert _run(404, 404) == "v1.10.0.dev0" + + +def test_first_ever_nightly_both_absent(): + assert _run(None, None) == "v1.10.0.dev0" + + +def test_base_version_bump_resets_counter(): + # Root pyproject bumped to 1.10.1; old 1.10.0 devs must not leak into the new series. + old_series = [f"1.10.0.dev{n}" for n in range(55)] + assert _run(old_series, old_series, base_version="1.10.1") == "v1.10.1.dev0" + + +def test_base_version_bump_with_some_new_series_releases(): + main_hist = ["1.10.0.dev54", "1.10.1.dev0", "1.10.1.dev1"] + base_hist = ["1.10.0.dev48", "1.10.1.dev0"] + assert _run(main_hist, base_hist, base_version="1.10.1") == "v1.10.1.dev2" + + +def test_one_present_one_404(): + assert _run(["1.10.0.dev54"], 404) == "v1.10.0.dev55" + assert _run(404, ["1.10.0.dev70"]) == "v1.10.0.dev71" + + +def test_final_release_does_not_advance_counter(): + # A non-dev/final release must not bump the nightly dev counter. + assert _run(["1.10.0", "1.10.0.dev3"], ["1.10.0.dev2"]) == "v1.10.0.dev4" + + +def test_only_final_releases_yield_dev0(): + assert _run(["1.10.0"], ["1.10.0"]) == "v1.10.0.dev0" + + +def test_malformed_response_raises(): + # A 200 response missing "releases" is fatal (fail closed), not silently ignored. + with pytest.raises(RuntimeError): + _run("malformed", ["1.10.0.dev10"]) + + +def test_server_error_raises(): + # A non-404 HTTP status on the higher-versioned package must abort, not emit a stale tag. + with pytest.raises(requests.HTTPError): + _run(500, ["1.10.0.dev48"]) + + +def test_higher_package_lookup_failure_aborts(): + # P1 regression: langflow-nightly (dev54) lookup fails transiently while + # langflow-base-nightly reports dev48. Must NOT emit v1.10.0.dev49 -- must raise so the job + # stops before deleting/recreating tags or republishing an already-existing version. + with pytest.raises(requests.HTTPError): + _run(503, [f"1.10.0.dev{n}" for n in range(49)]) + + +def test_network_error_raises(): + # A transient connection failure must abort rather than lower the computed dev number. + def boom(url, timeout=10): # noqa: ARG001 + raise requests.ConnectionError("transient") + + with ( + mock.patch.object(nt.requests, "get", side_effect=boom), + mock.patch.object(nt, "_root_base_version", return_value="1.10.0"), + pytest.raises(requests.ConnectionError), + ): + nt.create_tag("both") + + +def test_unparseable_version_string_is_skipped(): + assert _run(["not-a-version", "1.10.0.dev9"], None) == "v1.10.0.dev10" + + +def test_invalid_build_type_raises(): + with pytest.raises(ValueError, match="Invalid build type"): + _run(["1.10.0.dev1"], ["1.10.0.dev1"], build_type="frontend") + + +def _main(): + """Run all tests without pytest (mirrors test_constraint_preservation.py).""" + 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 pypi_nightly_tag tests passed.") + + +if __name__ == "__main__": + _main() diff --git a/scripts/ci/update_pyproject_combined.py b/scripts/ci/update_pyproject_combined.py index 4ad73bbfa7..997c2f9586 100755 --- a/scripts/ci/update_pyproject_combined.py +++ b/scripts/ci/update_pyproject_combined.py @@ -36,6 +36,12 @@ def main(): base_tag = sys.argv[3] lfx_tag = sys.argv[4] + # Lockstep invariant: langflow-base-nightly's published version (set here from `base_tag`) + # and langflow-nightly's exact `==` pin on it (set below from the same `base_tag`) MUST come + # from the same value, so the latest langflow-nightly always pins a base version published in + # the same run. `pypi_nightly_tag.py` makes the main and base tags identical; keep both writes + # sourced from `base_tag` or the pin can reference a version that was never published. + # First handle base package updates update_pyproject_name("src/backend/base/pyproject.toml", "langflow-base-nightly") update_name_uv_dep("pyproject.toml", "langflow-base-nightly")