Skip to content

Commit 7c01482

Browse files
authored
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.
1 parent 30b8977 commit 7c01482

7 files changed

Lines changed: 344 additions & 88 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CI Scripts Tests
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "scripts/ci/**"
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
name: Test CI release scripts
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.13"
22+
23+
- name: Install dependencies
24+
run: pip install pytest requests packaging
25+
26+
- name: Run CI script tests
27+
run: python -m pytest scripts/ci/ -v

.github/workflows/nightly_build.yml

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
# Required to create tag
8383
contents: write
8484
outputs:
85-
release_tag: ${{ steps.generate_release_tag.outputs.release_tag }}
85+
release_tag: ${{ steps.generate_shared_tag.outputs.release_tag }}
8686
base_tag: ${{ steps.set_base_tag.outputs.base_tag }}
8787
lfx_tag: ${{ steps.generate_lfx_tag.outputs.lfx_tag }}
8888
sdk_tag: ${{ steps.generate_sdk_tag.outputs.sdk_tag }}
@@ -107,30 +107,32 @@ jobs:
107107
run: |
108108
echo "PWD: $(pwd)"
109109
find . -name "pypi_nightly_tag.py"
110-
- name: Generate main nightly tag
111-
id: generate_release_tag
110+
- name: Generate shared nightly tag (main == base, lockstep)
111+
id: generate_shared_tag
112112
run: |
113+
# langflow-nightly pins langflow-base-nightly[complete]==<exact dev>, so both tags
114+
# MUST be identical. Compute them in ONE invocation (single PyPI snapshot) so they
115+
# cannot drift across separate calls.
113116
# NOTE: This outputs the tag with the `v` prefix.
114-
RELEASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py main)"
117+
TAGS_OUTPUT="$(uv run ./scripts/ci/pypi_nightly_tag.py both)"
118+
RELEASE_TAG="$(printf '%s\n' "$TAGS_OUTPUT" | sed -n '1p')"
119+
BASE_TAG="$(printf '%s\n' "$TAGS_OUTPUT" | sed -n '2p')"
120+
if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" != "$BASE_TAG" ]; then
121+
echo "Shared tag mismatch: release='$RELEASE_TAG' base='$BASE_TAG'. Exiting the workflow."
122+
exit 1
123+
fi
115124
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
116-
echo "release_tag=$RELEASE_TAG"
125+
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
126+
echo "release_tag=$RELEASE_TAG base_tag=$BASE_TAG"
117127
118128
- name: Delete existing tag if it exists
119129
id: check_release_tag
120130
run: |
121131
git fetch --tags
122-
git tag -d ${{ steps.generate_release_tag.outputs.release_tag }} || true
123-
git push --delete origin ${{ steps.generate_release_tag.outputs.release_tag }} || true
132+
git tag -d ${{ steps.generate_shared_tag.outputs.release_tag }} || true
133+
git push --delete origin ${{ steps.generate_shared_tag.outputs.release_tag }} || true
124134
echo "release_tag_exists=false" >> $GITHUB_OUTPUT
125135
126-
- name: Generate base nightly tag
127-
id: generate_base_tag
128-
run: |
129-
# NOTE: This outputs the tag with the `v` prefix.
130-
BASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py base)"
131-
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
132-
echo "base_tag=$BASE_TAG"
133-
134136
- name: Generate LFX nightly tag
135137
id: generate_lfx_tag
136138
run: |
@@ -155,8 +157,8 @@ jobs:
155157
git config --global user.email "bot-nightly-builds@langflow.org"
156158
git config --global user.name "Langflow Bot"
157159
158-
RELEASE_TAG="${{ steps.generate_release_tag.outputs.release_tag }}"
159-
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
160+
RELEASE_TAG="${{ steps.generate_shared_tag.outputs.release_tag }}"
161+
BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}"
160162
LFX_TAG="${{ steps.generate_lfx_tag.outputs.lfx_tag }}"
161163
SDK_TAG="${{ steps.generate_sdk_tag.outputs.sdk_tag }}"
162164
echo "Updating SDK project version to $SDK_TAG"
@@ -195,7 +197,7 @@ jobs:
195197
- name: Checkout main nightly tag
196198
uses: actions/checkout@v6
197199
with:
198-
ref: ${{ steps.generate_release_tag.outputs.release_tag }}
200+
ref: ${{ steps.generate_shared_tag.outputs.release_tag }}
199201
persist-credentials: true
200202

201203
- name: Retrieve Base Tag
@@ -214,8 +216,8 @@ jobs:
214216
BASE_TAG="${{ steps.retrieve_base_tag.outputs.base_tag }}"
215217
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
216218
echo "base_tag=$BASE_TAG"
217-
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_base_tag.outputs.base_tag }}" ]; then
218-
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
219+
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_shared_tag.outputs.base_tag }}" ]; then
220+
BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}"
219221
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
220222
echo "base_tag=$BASE_TAG"
221223
else

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@
187187
"filename": ".github/workflows/nightly_build.yml",
188188
"hashed_secret": "3e26d6750975d678acb8fa35a0f69237881576b0",
189189
"is_verified": false,
190-
"line_number": 303,
190+
"line_number": 305,
191191
"is_secret": false
192192
}
193193
],
@@ -8272,5 +8272,5 @@
82728272
}
82738273
]
82748274
},
8275-
"generated_at": "2026-05-26T14:34:58Z"
8275+
"generated_at": "2026-05-29T18:18:04Z"
82768276
}

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@ external = ["RUF027"]
271271
"TRY003", # Long exception messages
272272
"EM101", # String literals in exceptions
273273
]
274+
"scripts/ci/test_*.py" = [
275+
"D1", # Missing docstrings
276+
"PLR2004", # Magic value comparisons
277+
"S101", # Use of assert (required for pytest)
278+
"TRY003", # Long exception messages
279+
"EM101", # String literals in exceptions
280+
]
274281
"src/backend/base/langflow/alembic/versions/*" = ["INP001", "D415", "PGH003"]
275282
"src/backend/base/langflow/custom/__init__.py" = [
276283
"I001", # Import order affects initialization - must import custom module first

scripts/ci/pypi_nightly_tag.py

Lines changed: 88 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,122 @@
11
#!/usr/bin/env python
2-
"""Idea from https://github.com/streamlit/streamlit/blob/4841cf91f1c820a392441092390c4c04907f9944/scripts/pypi_nightly_create_tag.py."""
2+
"""Idea from https://github.com/streamlit/streamlit/blob/4841cf91f1c820a392441092390c4c04907f9944/scripts/pypi_nightly_create_tag.py.
3+
4+
`langflow-nightly` pins an EXACT dependency on `langflow-base-nightly[complete]==X.Y.Z.devN`.
5+
For the latest published `langflow-nightly` to be installable, the base version it pins must
6+
exist on PyPI. The two packages are therefore versioned in lockstep: they share a single dev
7+
number so that, in a single nightly run (publish order base -> main, gated), main's `devN` pin
8+
always references the base `devN` built and published in the same run.
9+
10+
The shared dev number is `max(dev across BOTH packages' PyPI histories) + 1`, restricted to
11+
releases whose base_version matches the root pyproject. Both "main" and "base" build types
12+
return the identical tag; the "both" mode emits it twice so the workflow can read the release
13+
and base tags from a single invocation (one PyPI snapshot) and avoid any cross-call drift.
14+
"""
315

416
import sys
17+
from pathlib import Path
518

619
import packaging.version
720
import requests
821
from packaging.version import Version
922

10-
PYPI_LANGFLOW_URL = "https://pypi.org/pypi/langflow/json"
1123
PYPI_LANGFLOW_NIGHTLY_URL = "https://pypi.org/pypi/langflow-nightly/json"
12-
13-
PYPI_LANGFLOW_BASE_URL = "https://pypi.org/pypi/langflow-base/json"
1424
PYPI_LANGFLOW_BASE_NIGHTLY_URL = "https://pypi.org/pypi/langflow-base-nightly/json"
1525

26+
# main and base MUST share one dev number, so the shared number is derived from both packages.
27+
PYPI_NIGHTLY_URLS = (PYPI_LANGFLOW_NIGHTLY_URL, PYPI_LANGFLOW_BASE_NIGHTLY_URL)
28+
1629
ARGUMENT_NUMBER = 2
30+
VALID_BUILD_TYPES = ("main", "base", "both")
1731

1832

19-
def get_latest_published_version(build_type: str, *, is_nightly: bool) -> Version:
20-
url = ""
21-
if build_type == "base":
22-
url = PYPI_LANGFLOW_BASE_NIGHTLY_URL if is_nightly else PYPI_LANGFLOW_BASE_URL
23-
elif build_type == "main":
24-
url = PYPI_LANGFLOW_NIGHTLY_URL if is_nightly else PYPI_LANGFLOW_URL
25-
else:
26-
msg = f"Invalid build type: {build_type}"
27-
raise ValueError(msg)
33+
def _root_base_version() -> str:
34+
"""Return the base_version (e.g. "1.10.0") from the root pyproject.toml.
35+
36+
Both langflow-nightly and langflow-base-nightly are versioned from the ROOT pyproject on
37+
purpose. Do not switch base to read src/backend/base/pyproject.toml, or the two dev counters
38+
will fork again and the exact `==` pin can reference a version that was never published.
39+
"""
40+
import tomllib
41+
42+
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
43+
pyproject_data = tomllib.loads(pyproject_path.read_text())
44+
return Version(pyproject_data["project"]["version"]).base_version
45+
2846

47+
def _all_dev_numbers(url: str, base_version: str) -> list[int]:
48+
"""Dev numbers of every release of url whose base_version matches base_version.
49+
50+
A 404 means the package genuinely has no releases yet (e.g. the first-ever nightly): it
51+
contributes nothing and returns an empty list. Every OTHER failure -- a network error, a
52+
non-404 HTTP status (5xx / 403 / ...), or a malformed 200 response -- is fatal and raises,
53+
so the nightly job aborts BEFORE mutating tags. Failing closed prevents a transient lookup
54+
failure on the higher-versioned package from lowering max(dev) + 1 and regenerating an
55+
already-published version. Non-dev/final releases and releases from another base_version
56+
never contribute.
57+
"""
2958
res = requests.get(url, timeout=10)
59+
if res.status_code == requests.codes.not_found:
60+
return []
3061
res.raise_for_status()
3162
try:
32-
version_str = res.json()["info"]["version"]
33-
except Exception as e:
34-
msg = "Got unexpected response from PyPI"
63+
releases = res.json()["releases"]
64+
except (ValueError, KeyError) as e:
65+
msg = f"Unexpected response from {url!r}: missing 'releases' mapping"
3566
raise RuntimeError(msg) from e
36-
return Version(version_str)
3767

68+
dev_numbers: list[int] = []
69+
for version_str in releases:
70+
try:
71+
version = Version(version_str)
72+
except packaging.version.InvalidVersion:
73+
continue
74+
if version.base_version == base_version and version.dev is not None:
75+
dev_numbers.append(version.dev)
76+
return dev_numbers
3877

39-
def create_tag(build_type: str):
40-
from pathlib import Path
4178

42-
import tomllib
79+
def _shared_nightly_version() -> str:
80+
"""Compute the single dev number shared by langflow-nightly and langflow-base-nightly."""
81+
base_version = _root_base_version()
4382

44-
# Read version from pyproject.toml
45-
main_tag_pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
46-
pyproject_data = tomllib.loads(main_tag_pyproject_path.read_text())
83+
dev_numbers = [dev for url in PYPI_NIGHTLY_URLS for dev in _all_dev_numbers(url, base_version)]
4784

48-
current_version_str = pyproject_data["project"]["version"]
49-
current_version = Version(current_version_str)
85+
# First-ever nightly for this base_version -> dev0. Otherwise max+1, so the result is
86+
# strictly ahead of BOTH packages' newest same-series dev release.
87+
next_dev = max(dev_numbers) + 1 if dev_numbers else 0
5088

51-
try:
52-
current_nightly_version = get_latest_published_version(build_type, is_nightly=True)
53-
except (requests.RequestException, KeyError, ValueError):
54-
# If nightly doesn't exist yet
55-
current_nightly_version = None
56-
57-
build_number = "0"
58-
latest_base_version = current_version.base_version
59-
nightly_base_version = current_nightly_version.base_version if current_nightly_version else None
60-
61-
if latest_base_version == nightly_base_version:
62-
# If the latest version is the same as the nightly version, increment the build number
63-
dev_number = (current_nightly_version.dev or 0) if current_nightly_version else 0
64-
build_number = str(dev_number + 1)
65-
66-
new_nightly_version = latest_base_version + ".dev" + build_number
67-
68-
# Prepend "v" to the version, if DNE.
69-
# This is an update to the nightly version format.
70-
if not new_nightly_version.startswith("v"):
71-
new_nightly_version = "v" + new_nightly_version
72-
73-
# X.Y.Z.dev.YYYYMMDD
74-
# This takes the base version of the current version and appends the
75-
# current date. If the last release was on the same day, we exit, as
76-
# pypi does not allow for overwriting the same version.
77-
78-
# We could use a different versioning scheme, such as just incrementing
79-
# an integer.
80-
# version_with_date = (
81-
# ".".join([str(x) for x in current_version.release])
82-
# + ".dev"
83-
# + "0"
84-
# + datetime.now(pytz.timezone("UTC")).strftime("%Y%m%d")
85-
# )
86-
87-
# Verify if version is PEP440 compliant.
89+
new_nightly_version = f"v{base_version}.dev{next_dev}"
90+
91+
# Verify the version is PEP 440 compliant.
8892
packaging.version.Version(new_nightly_version)
8993

9094
return new_nightly_version
9195

9296

97+
def create_tag(build_type: str) -> str:
98+
"""Return the shared nightly tag (with a leading ``v``).
99+
100+
``build_type`` is accepted for backward compatibility and validated, but "main" and "base"
101+
always return the identical version by design (lockstep versioning).
102+
"""
103+
if build_type not in VALID_BUILD_TYPES:
104+
msg = f"Invalid build type: {build_type}"
105+
raise ValueError(msg)
106+
return _shared_nightly_version()
107+
108+
93109
if __name__ == "__main__":
94110
if len(sys.argv) != ARGUMENT_NUMBER:
95-
msg = "Specify base or main"
111+
msg = "Specify base, main, or both"
96112
raise ValueError(msg)
97113

98-
build_type = sys.argv[1]
99-
tag = create_tag(build_type)
100-
print(tag)
114+
requested_build_type = sys.argv[1]
115+
tag = create_tag(requested_build_type)
116+
if requested_build_type == "both":
117+
# Emit twice so the workflow can capture release_tag and base_tag from a SINGLE
118+
# invocation -> one PyPI snapshot -> guaranteed-identical tags.
119+
print(tag)
120+
print(tag)
121+
else:
122+
print(tag)

0 commit comments

Comments
 (0)