fix(ci): lockstep langflow-nightly and langflow-base-nightly versions (forward-port #13413) (#13416)

fix(ci): lockstep langflow-nightly and langflow-base-nightly versions

Forward-port of #13413 (merged to release-1.10.0) to main.

pypi_nightly_tag.py now derives a single shared dev number from
max(dev across BOTH langflow-nightly and langflow-base-nightly PyPI
histories) + 1 (restricted to the root base_version) and emits it twice in
`both` mode. nightly_build.yml reads the release and base tags from one
invocation (single PyPI snapshot) and fails closed on mismatch, so the latest
langflow-nightly always pins a langflow-base-nightly[complete]==X.Y.Z.devN
that was built and published in the same run (no more uninstallable nightly).

Safe to land now: the nightly create-nightly-tag job checks out the latest
release-* branch for the script while taking the run-block from the triggering
ref (main for scheduled runs). release-1.10.0 already carries the new script
(via #13413), so main's `both` run-block matches it, and future release
branches cut from main inherit the lockstep script.

Adds scripts/ci/test_pypi_nightly_tag.py + .github/workflows/ci-scripts-test.yml
and the update_pyproject_combined.py lockstep-invariant note.
This commit is contained in:
Eric Hare
2026-05-29 11:19:29 -07:00
committed by GitHub
parent 30b89779b7
commit 7c01482fa1
7 changed files with 342 additions and 86 deletions

27
.github/workflows/ci-scripts-test.yml vendored Normal file
View File

@ -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

View File

@ -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]==<exact dev>, 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

View File

@ -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"
}

View File

@ -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

View File

@ -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)

View File

@ -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/<pkg>/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": {"<version>": [<files>], ...}}.
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()

View File

@ -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")