Skip to content

Commit 2dceda4

Browse files
committed
Pin providers in constraints to the versions published in PyPI
Constraints cut for a release candidate had to land on the wave being voted on, whose providers exist in PyPI only as rc versions. Asking uv for a pre-release strategy left it free to answer with any version satisfying the lower bounds, so the pins were neither the candidate nor the last release, and what a candidate shipped depended on how the resolution happened to go. Retrieving the versions from PyPI and naming them leaves nothing to resolve: the constraints pin what is actually published. A pre-release is only ever considered when the run allows it, and even then it has to sort above every final release, so a provider without a candidate in the wave keeps its release and a released constraints file can never carry an rc pin. A candidate is exempt from the downgrade check - it sorts below the releases the constraints branch already carries, so the comparison says nothing there. Re-running the workflow for the same candidate now replaces that candidate's branch and tag instead of pushing onto them, so the two always describe the run that produced them and a wave can be re-cut.
1 parent fd0f7e2 commit 2dceda4

4 files changed

Lines changed: 295 additions & 81 deletions

File tree

.github/workflows/release-constraints.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
#
2121
# The stage is derived from the version, so the two cannot be mismatched by hand:
2222
#
23-
# * a candidate (`3.1.3rc1`) resolves with pre-releases allowed - the providers of the wave
24-
# being voted on exist on PyPI only as rc versions - and lands on a branch of its own, so a
25-
# candidate never moves the branch every other build reads;
26-
# * a final (`3.1.3`) resolves without them, against the providers now published as finals, and
23+
# * a candidate (`3.1.3rc1`) pins the providers at the versions PyPI holds - the wave being
24+
# voted on exists there only as rc versions - and lands on a branch of its own, so a candidate
25+
# never moves the branch every other build reads. Re-running it for the same candidate
26+
# replaces that branch and its tag, so the two always describe the run that produced them;
27+
# * a final (`3.1.3`) pins the providers at their released versions, ignoring any candidate, and
2728
# commits onto `constraints-X-Y` itself, which is what makes the released constraints the
2829
# baseline everything downstream reads.
2930
#
@@ -203,6 +204,7 @@ jobs:
203204
VERSION: ${{ inputs.version }}
204205
CONSTRAINTS_BRANCH: ${{ needs.build-info.outputs.constraints-branch }}
205206
TARGET_BRANCH: ${{ needs.build-info.outputs.target-branch }}
207+
ALLOW_PRE_RELEASES: ${{ needs.build-info.outputs.allow-pre-releases }}
206208
steps:
207209
- name: "Cleanup repo"
208210
shell: bash
@@ -226,6 +228,26 @@ jobs:
226228
with:
227229
pattern: constraints-*
228230
path: ./files
231+
# A candidate's branch and tag belong to that candidate alone, so re-running for the same
232+
# rc replaces them rather than adding to them: the branch would otherwise already hold the
233+
# previous run's constraints (making the push a non-fast-forward) and the tag already exist.
234+
# A final never gets this - it commits onto the shared constraints-X-Y branch, whose history
235+
# every other build reads.
236+
- name: "Delete the previous ${{ needs.build-info.outputs.target-branch }} branch and tag"
237+
if: needs.build-info.outputs.allow-pre-releases == 'true'
238+
working-directory: "constraints"
239+
shell: bash
240+
run: |
241+
if git ls-remote --exit-code origin "refs/heads/${TARGET_BRANCH}" > /dev/null; then
242+
echo "Deleting the existing '${TARGET_BRANCH}' branch."
243+
git push origin --delete "refs/heads/${TARGET_BRANCH}"
244+
fi
245+
if git ls-remote --exit-code origin "refs/tags/constraints-${VERSION}" > /dev/null; then
246+
echo "Deleting the existing 'constraints-${VERSION}' tag."
247+
git push origin --delete "refs/tags/constraints-${VERSION}"
248+
fi
249+
git tag --delete "constraints-${VERSION}" > /dev/null 2>&1 || true
250+
git branch --delete --force "${TARGET_BRANCH}" > /dev/null 2>&1 || true
229251
- name: "Switch to ${{ needs.build-info.outputs.target-branch }}"
230252
working-directory: "constraints"
231253
shell: bash

dev/breeze/src/airflow_breeze/utils/release_constraints.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@
3939
def publish_constraints(*, version: str, ref: str, workflow_branch: str = "main") -> None:
4040
"""Resolve, publish and tag the constraints belonging to ``version``.
4141
42-
``version`` alone decides what happens: a candidate (``3.1.3rc1``) resolves with pre-releases
43-
allowed - the providers of the wave being voted on are on PyPI only as rc versions - and lands
44-
on a branch of its own, leaving the branch every other build reads where it was. A final
45-
(``3.1.3``) resolves without them and commits onto ``constraints-X-Y``, which is what makes the
46-
released constraints the baseline everything downstream reads.
42+
``version`` alone decides what happens: a candidate (``3.1.3rc1``) pins the providers at the
43+
versions PyPI holds - the wave being voted on is there only as rc versions - and lands on a
44+
branch of its own, leaving the branch every other build reads where it was. Re-running it for
45+
the same candidate replaces that branch and its tag, so a wave can be re-cut. A final
46+
(``3.1.3``) resolves against the published releases and commits onto ``constraints-X-Y``, which
47+
is what makes the released constraints the baseline everything downstream reads.
4748
"""
4849
stage = "candidate" if "rc" in version else "final"
4950
if not confirm_action(

scripts/in_container/run_generate_constraints.py

Lines changed: 119 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,19 @@
2121
import json
2222
import os
2323
import sys
24+
from concurrent.futures import ThreadPoolExecutor
2425
from dataclasses import dataclass
2526
from datetime import datetime
2627
from functools import cached_property
28+
from itertools import repeat
2729
from pathlib import Path
2830
from typing import TextIO
2931

3032
import requests
3133
from click import Choice
3234
from in_container_utils import AIRFLOW_DIST_PATH, AIRFLOW_ROOT_PATH, click, console, run_command
35+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
36+
from packaging.version import InvalidVersion, Version
3337

3438
try:
3539
import tomllib
@@ -39,6 +43,8 @@
3943
DEFAULT_BRANCH = os.environ.get("DEFAULT_BRANCH", "main")
4044
PYTHON_VERSION = os.environ.get("PYTHON_MAJOR_MINOR_VERSION", "3.10")
4145
GENERATED_PROVIDER_DEPENDENCIES_FILE = AIRFLOW_ROOT_PATH / "generated" / "provider_dependencies.json"
46+
PYPI_JSON_API_URL = "https://pypi.org/pypi/{distribution}/json"
47+
PYPI_LOOKUP_PARALLELISM = 8
4248

4349

4450
def _read_version_from_pyproject(pyproject_path: Path) -> str:
@@ -380,11 +386,19 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None:
380386
Fail generation if any released provider is downgraded compared to the latest constraints.
381387
382388
Released provider versions only ever move forward on PyPI, so a lower version in the freshly
383-
generated constraints signals a resolution problem (a broken dependency forcing an old provider
384-
back in) rather than an intended change. We stop here so it is caught instead of being published.
385-
"""
386-
from packaging.version import InvalidVersion, Version
389+
generated constraints means the version we had pinned is no longer installable - yanked, or
390+
left without a file for the Python being resolved. We stop here so it is caught instead of
391+
being published.
387392
393+
A release candidate is exempt: it pins the wave being voted on, which sorts below the released
394+
versions a constraints branch may already carry, so the comparison says nothing there.
395+
"""
396+
if config_params.allow_pre_releases:
397+
console.print(
398+
"[yellow]Pre-releases are allowed - skipping the provider downgrade check, a candidate "
399+
"sorts below the releases the constraints branch carries."
400+
)
401+
return
388402
if not config_params.latest_constraints_file.exists():
389403
console.print("[yellow]No previous constraints file downloaded - skipping provider downgrade check.")
390404
return
@@ -408,10 +422,10 @@ def check_providers_not_downgraded(config_params: ConfigParams) -> None:
408422
for provider, latest_version, current_version in sorted(downgraded):
409423
console.print(f"[red] * {provider}: {latest_version} -> {current_version}")
410424
console.print(
411-
"[yellow]Released providers should never be downgraded. This usually means a broken "
412-
"dependency version forced an older provider back in during resolution. Investigate the "
413-
"diff above and, if needed, add an exclusion in the "
414-
f"`additional_constraints_for_highest_resolution` list in [/] {__file__}"
425+
"[yellow]Released providers should never be downgraded. The providers are pinned at the "
426+
"newest version PyPI serves for this Python, so a lower one means the version we had is "
427+
"gone - yanked, or no longer offering a file this Python can install. Investigate the "
428+
"diff above before publishing.[/]"
415429
)
416430
write_provider_downgrade_slack_message(config_params, downgraded)
417431
sys.exit(1)
@@ -459,16 +473,82 @@ def get_all_active_provider_distributions(python_version: str | None = None) ->
459473
]
460474

461475

462-
def build_provider_pre_release_requirements(python_version: str) -> list[str]:
463-
"""Requirements that let only the providers resolve to a pre-release.
464-
465-
uv considers a pre-release for a package only when some requirement for it mentions one, so a
466-
pre-release lower bound on each provider confines the allowance to them. `--pre` would apply to
467-
the whole resolution and could put a pre-release of any third-party dependency into the
468-
constraints a release ships.
476+
def is_file_installable(pypi_file: dict, target_python: Version) -> bool:
477+
"""Whether a file PyPI lists for a release can be installed on ``target_python``."""
478+
if pypi_file.get("yanked"):
479+
return False
480+
requires_python = pypi_file.get("requires_python")
481+
if not requires_python:
482+
return True
483+
try:
484+
return target_python in SpecifierSet(requires_python)
485+
except InvalidSpecifier:
486+
return True
487+
488+
489+
def find_newest_version_in_pypi(
490+
distribution: str, python_version: str, allow_pre_releases: bool
491+
) -> str | None:
492+
"""Return the newest version of ``distribution`` PyPI can install for ``python_version``.
493+
494+
A pre-release is only ever considered when ``allow_pre_releases`` is set, and even then it wins
495+
only by sorting above every final release - so a provider with a candidate in the wave being
496+
voted on resolves to that candidate while one without keeps its last release. Versions PyPI can
497+
no longer serve for the Python being resolved - fully yanked, or excluded by ``requires_python``
498+
- are passed over, because pinning one leaves the resolution nothing to install. ``None``
499+
(nothing installable at all, e.g. a provider whose first release is still in this wave) leaves
500+
the distribution unpinned rather than pinned to a version that cannot be had.
469501
"""
502+
response = requests.get(PYPI_JSON_API_URL.format(distribution=distribution), timeout=60)
503+
if response.status_code == 404:
504+
console.print(f"[yellow]{distribution} is not published in PyPI - leaving it unpinned.")
505+
return None
506+
response.raise_for_status()
507+
target_python = Version(python_version)
508+
newest_version: Version | None = None
509+
for version, files in response.json().get("releases", {}).items():
510+
try:
511+
parsed_version = Version(version)
512+
except InvalidVersion:
513+
continue
514+
if parsed_version.is_prerelease and not allow_pre_releases:
515+
continue
516+
if not any(is_file_installable(pypi_file, target_python) for pypi_file in files):
517+
continue
518+
if newest_version is None or parsed_version > newest_version:
519+
newest_version = parsed_version
520+
if newest_version is None:
521+
console.print(f"[yellow]{distribution} has no installable version in PyPI - leaving it unpinned.")
522+
return None
523+
return str(newest_version)
524+
525+
526+
def build_pinned_provider_requirements(python_version: str, allow_pre_releases: bool) -> list[str]:
527+
"""Exact pins for every active provider, at the newest version PyPI holds for it.
528+
529+
Naming the versions retrieved from PyPI leaves the providers nothing to resolve: the constraints
530+
pin what is actually published rather than whatever the resolver settles on. It is what makes a
531+
release candidate land on the wave being voted on, whose providers are published only as rc
532+
versions - handing uv a pre-release strategy instead left it free to answer with any version
533+
satisfying the lower bounds. It also keeps pre-releases confined to the providers: no
534+
third-party dependency can answer with one, because no requirement here mentions a pre-release
535+
of anything else.
536+
"""
537+
distributions = get_all_active_provider_distributions(python_version)
538+
console.print(f"[bright_blue]Retrieving the newest PyPI version of {len(distributions)} providers.")
539+
with ThreadPoolExecutor(max_workers=PYPI_LOOKUP_PARALLELISM) as executor:
540+
newest_versions = list(
541+
executor.map(
542+
find_newest_version_in_pypi,
543+
distributions,
544+
repeat(python_version),
545+
repeat(allow_pre_releases),
546+
)
547+
)
470548
return [
471-
f"{distribution}>=0.0.0rc0" for distribution in get_all_active_provider_distributions(python_version)
549+
f"{distribution}=={version}"
550+
for distribution, version in zip(distributions, newest_versions)
551+
if version is not None
472552
]
473553

474554

@@ -532,17 +612,13 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
532612
# that the resolver will not downgrade the provider.
533613
# * opentelemetry-exporter-prometheus>=0.47b0 — this package only ever publishes beta versions
534614
# (airflow-core requires ``>=0.47b0`` and released constraints already pin a beta, e.g.
535-
# ``==0.65b0``). For a release candidate the resolution runs with ``--prerelease explicit``,
536-
# which permits a pre-release only for a package some requirement marks as such and drops the
537-
# if-necessary fallback; without this entry the package cannot resolve and generation fails
538-
# with "No solution found". Keeping it here (rather than the provider pre-release list) marks
539-
# it as an always-allowed pre-release across every resolution, matching how it already ships.
615+
# ``==0.65b0``). Marking it as a pre-release here rather than relying on uv's if-necessary
616+
# fallback keeps the choice deliberate and identical across every resolution.
540617
# * opentelemetry-semantic-conventions>=0.48b0 — same story: a beta-only package, pulled in as a
541-
# hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability),
542-
# so the ``--prerelease explicit`` resolution needs the same explicit mark. The floor only marks
543-
# it as a pre-release and must stay at the version paired with our ``opentelemetry-*>=1.27.0``
544-
# floor — opentelemetry-sdk exact-pins the semantic conventions version it ships with, so a
545-
# higher floor here would conflict with any sdk older than that pairing.
618+
# hard dependency of opentelemetry-sdk (via opentelemetry-exporter-otlp and shared/observability).
619+
# The floor only marks it as a pre-release and must stay at the version paired with our
620+
# ``opentelemetry-*>=1.27.0`` floor — opentelemetry-sdk exact-pins the semantic conventions
621+
# version it ships with, so a higher floor here would conflict with any sdk older than that pairing.
546622
#
547623
# These two are the only pre-releases in the constraints we tag, and removing the need for the
548624
# exception is tracked at https://github.com/apache/airflow/issues/71176
@@ -554,27 +630,16 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
554630
"opentelemetry-semantic-conventions>=0.48b0",
555631
]
556632

557-
# Constraints cut for a release candidate have to pin the candidates themselves - the providers
558-
# for that wave exist on PyPI only as rcN versions, and uv will not resolve to a pre-release
559-
# unless asked. The final release regenerates these without it, so a released constraints file
560-
# can never carry an rc pin.
561-
#
562-
# Scoped to the providers rather than passing `--pre`, which applies to the whole resolution
563-
# and would let any dependency answer with a pre-release - putting, say, a beta of a
564-
# third-party library into the constraints a release ships. uv has no per-package pre-release
565-
# flag, so the scoping is expressed the way it does support: `explicit` permits a pre-release
566-
# only for a package some requirement marks as such, and the rc lower bound below is that mark.
567-
# `explicit` rather than the default `if-necessary-or-explicit` also drops the fallback that
568-
# would otherwise let an unmarked package resolve to a pre-release when no final satisfies.
569-
pre_release_requirements: list[str] = []
570-
pre_release_strategy: list[str] = []
571-
if config_params.allow_pre_releases:
572-
pre_release_requirements = build_provider_pre_release_requirements(config_params.python)
573-
pre_release_strategy = ["--prerelease", "explicit"]
574-
console.print(
575-
f"[bright_blue]Allowing pre-releases for {len(pre_release_requirements)} provider "
576-
"distributions - no other package can resolve to one."
577-
)
633+
# Every run pins the providers at the versions PyPI holds when it is generated. Only a run for
634+
# a release candidate considers the rc versions of the wave being voted on; a final regenerates
635+
# these against the published releases, so a released constraints file can never carry an rc pin.
636+
pinned_provider_requirements = build_pinned_provider_requirements(
637+
config_params.python, config_params.allow_pre_releases
638+
)
639+
console.print(
640+
f"[bright_blue]Pinning {len(pinned_provider_requirements)} provider distributions to the "
641+
f"{'newest' if config_params.allow_pre_releases else 'newest final'} versions in PyPI."
642+
)
578643

579644
result = run_command(
580645
cmd=[
@@ -589,8 +654,7 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
589654
f"apache-airflow-task-sdk=={AIRFLOW_TASK_SDK_VERSION}",
590655
"./airflow-ctl",
591656
*additional_constraints_for_highest_resolution,
592-
*pre_release_requirements,
593-
*pre_release_strategy,
657+
*pinned_provider_requirements,
594658
"--reinstall", # We need to pull the provider distributions from PyPI or dist, not the local ones
595659
"--resolution",
596660
"highest",
@@ -603,7 +667,9 @@ def generate_constraints_pypi_providers(config_params: ConfigParams) -> None:
603667
if result.returncode != 0:
604668
console.print(
605669
"[red]Failed to install airflow with PyPI providers with highest resolution.[/]\n"
606-
"[yellow]Please check the output above for details. One of they ways how to resolve it, in "
670+
"[yellow]Please check the output above for details. The providers are pinned at the newest "
671+
"versions PyPI serves, so two of them requiring incompatible dependencies fails here rather "
672+
"than quietly settling on an older provider. One of they ways how to resolve it, in "
607673
"case it is caused by a specific broken dependency version, is to exclude it above in the "
608674
f"`additional_constraints_for_highest_resolution` list in [/] {__file__}"
609675
)
@@ -693,8 +759,9 @@ def generate_constraints_no_providers(config_params: ConfigParams) -> None:
693759
"--allow-pre-releases",
694760
is_flag=True,
695761
default=False,
696-
help="Allow pre-release versions of Airflow and providers to be pinned. Used when constraints "
697-
"are generated for a release candidate, whose providers are only on PyPI as rc versions.",
762+
help="Let the provider pins use pre-release versions when those are newer than any final "
763+
"release. Used when constraints are generated for a release candidate, whose providers are "
764+
"only on PyPI as rc versions.",
698765
envvar="ALLOW_PRE_RELEASES",
699766
)
700767
def generate_constraints(

0 commit comments

Comments
 (0)