Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 92 additions & 4 deletions .github/workflows/on_comment.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
#!/usr/bin/env python3
"""Parses issue and PR comments to dispatch release and backport workflows."""

import argparse
import json
import os
import re
import subprocess
import sys


def _load_event_data() -> dict:
"""Loads event JSON payload from GITHUB_EVENT_PATH."""
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path or not os.path.isfile(event_path):
return {}
try:
with open(event_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}


def _get_bool(key: str, default: bool = False) -> bool:
"""Returns boolean value for an environment variable."""
val = os.environ.get(key)
Expand Down Expand Up @@ -65,6 +79,21 @@ def _add_comment_reaction(repo: str, comment_id: str, content: str) -> None:
)


def _post_issue_comment(repo: str, issue_number: str, body: str) -> None:
"""Posts a comment to a GitHub issue or PR using the gh CLI."""
subprocess.run(
[
"gh",
"issue",
"comment",
issue_number,
f"--repo={repo}",
f"--body={body}",
],
check=True,
)


def _react_negative(repo: str, comment_id: str) -> None:
"""Logs error and adds a negative reaction to the comment."""
print("::error::No PRs specified for backport.")
Expand Down Expand Up @@ -151,8 +180,60 @@ def _process_pr_comment(comment_body: str, pr_number: str) -> None:
_write_github_output("command", "none")


def process_comment() -> int:
def _report_failure() -> int:
"""Posts a failure comment and negative reaction when a workflow fails."""
event = _load_event_data()
issue_data = event.get("issue") or {}
comment_data = event.get("comment") or {}
repo_data = event.get("repository") or {}

event_number = str(issue_data.get("number") or os.environ.get("EVENT_NUMBER", ""))
comment_id = str(comment_data.get("id") or os.environ.get("COMMENT_ID", ""))
comment_url = str(comment_data.get("html_url") or os.environ.get("COMMENT_URL", ""))
repo = str(repo_data.get("full_name") or os.environ.get("GITHUB_REPOSITORY", ""))
command = os.environ.get("COMMAND", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")

if comment_id and repo:
_add_comment_reaction(repo=repo, comment_id=comment_id, content="-1")

if not event_number or not repo:
print(
"::error::Issue number and repository are required to post a"
" failure comment."
)
return 1

if command and command != "none":
if comment_url:
header = (
f"Workflow failed for command `{command}` ([comment]({comment_url}))."
)
else:
header = f"Workflow failed for command `{command}`."
else:
if comment_url:
header = f"Workflow failed while processing [comment]({comment_url})."
else:
header = "Workflow failed while processing comment."

if run_id:
run_url = f"{server_url}/{repo}/actions/runs/{run_id}"
details = f"See [workflow run]({run_url}) for logs."
else:
details = "See workflow logs for details."

body = f"{header}\n\n{details}"
_post_issue_comment(repo=repo, issue_number=event_number, body=body)
return 0


def process_comment(*, report_failure: bool = False) -> int:
"""Processes a comment from environment variables and dispatches actions."""
if report_failure:
return _report_failure()

comment_body = os.environ.get("COMMENT_BODY", "")
is_pr = _get_bool("IS_PR")
event_number = os.environ.get("EVENT_NUMBER", "")
Expand Down Expand Up @@ -189,9 +270,16 @@ def process_comment() -> int:
return 0


def _main() -> None:
sys.exit(process_comment())
def _main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--report-failure",
action="store_true",
help="Post a failure comment to the issue or PR.",
)
args = parser.parse_args(argv if argv is not None else [])
sys.exit(process_comment(report_failure=args.report_failure))


if __name__ == "__main__":
_main()
_main(sys.argv[1:])
27 changes: 27 additions & 0 deletions .github/workflows/on_comment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,30 @@ jobs:
with:
issue: ${{ needs.parse_comment.outputs.issue_number }}
secrets: inherit

report_failure:
needs:
- parse_comment
- call_create_rc
- call_prepare_complete
- call_create_release_branch
- call_prepare
- call_add_backports
- call_process_backports_after_add
- call_process_backports_only
- call_sync_changelog
- call_promote
- call_backport_prepare
- call_backport_create_releases
if: always() && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Post failure comment
env:
COMMAND: "${{ needs.parse_comment.outputs.command }}"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/workflows/on_comment.py --report-failure
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).
in {obj}`python.defaults`, {obj}`python_register_toolchains`, and
{obj}`python_register_multi_toolchains`
([#4023](https://github.com/bazel-contrib/rules_python/pull/4023)).
* (toolchains) Shared `libpython*.so*` files are now excluded by default from
hermetic Python runtime files for recognized Astral `python-build-standalone`
builds (`20250517` and newer), which statically link `libpython` into the
interpreter. Older, custom, or unrecognized runtimes retain them. Set
`libpython = "include"` or `"exclude"` on {obj}`python.override`,
{obj}`python.single_version_override`,
{obj}`python.single_version_platform_override`, or
{obj}`python_register_toolchains` to override this behavior
([#4165](https://github.com/bazel-contrib/rules_python/pull/4165)).

{#v2-4-0-fixed}
### Fixed
Expand Down Expand Up @@ -81,6 +90,10 @@ changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).
* (toolchain) Fixed a crash on Windows when precompiling is enabled:
the precompiler's interpreter couldn't find its DLLs once relocated
([#4082](https://github.com/bazel-contrib/rules_python/issues/4082)).
* (zipapp) Reduced self-contained archive sizes by preserving Python executable
symlinks instead of storing each alias as another copy of the interpreter, and
by omitting shared `libpython` files from recognized statically linked Astral
runtime builds.

{#v2-4-0-added}
### Added
Expand All @@ -91,6 +104,7 @@ changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).
package directory, set it to `None` or `""` to set it to the root of the repo
([#4029](https://github.com/bazel-contrib/rules_python/issues/4029)).


{#v2-3-4}
## [2.3.4] - 2026-09-21

Expand Down
10 changes: 10 additions & 0 deletions docs/toolchains.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,22 @@ existing attributes:
via {attr}`python.override.minor_mapping`.
* Per-version control of the coverage tool used using
{attr}`python.single_version_platform_override.coverage_tool`.
* Control of shared `libpython` files using the `libpython` attribute on
{bzl:obj}`python.override`, {bzl:obj}`python.single_version_override`, or
{bzl:obj}`python.single_version_platform_override`.
* Adding additional Python versions via {bzl:obj}`python.single_version_override` or
{bzl:obj}`python.single_version_platform_override`.
* Adding additional Python versions dynamically from a manifest file or URL
via {attr}`python.override.add_runtime_manifest_files` or
{attr}`python.override.add_runtime_manifest_urls`.

The `libpython` attribute accepts `auto`, `include`, or `exclude`, and defaults
to `auto`. In automatic mode, shared `libpython` files are excluded only for
recognized Astral Python Standalone builds from `20250517` onward, which are
known to statically link `libpython` into the interpreter. Unknown, custom, and
older runtimes retain the shared libraries. Use `include` or `exclude` to
override this behavior when mirroring or customizing a runtime.

### Registering custom runtimes

Because the python-build-standalone project has _thousands_ of prebuilt runtimes
Expand Down
6 changes: 6 additions & 0 deletions news/4184.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
(pypi) A {obj}`pip.parse` call for a Python version that has no interpreter for
the host platform is now skipped, like a version with no registered toolchain,
instead of failing the whole {obj}`pip` extension. A dependency that lists,
say, Python 3.9 no longer breaks every other hub on hosts that have no CPython
3.9 build, such as Windows on ARM64
([#4184](https://github.com/bazel-contrib/rules_python/issues/4184)).
1 change: 1 addition & 0 deletions python/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ bzl_library(
srcs = ["python_repository.bzl"],
deps = [
":auth",
":pbs_manifest",
":repo_utils",
":text_util",
"//python:versions",
Expand Down
5 changes: 5 additions & 0 deletions python/private/hermetic_runtime_repo_setup.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def define_hermetic_runtime_toolchain_impl(
name,
extra_files_glob_include,
extra_files_glob_exclude,
interpreter_has_static_libpython = False,
python_version,
python_bin,
coverage_tool):
Expand All @@ -45,6 +46,8 @@ def define_hermetic_runtime_toolchain_impl(
binaries).
extra_files_glob_exclude: {type}`list[str]` additional glob exclude
patterns for the target runtime files.
interpreter_has_static_libpython: {type}`bool` whether the interpreter
includes libpython statically.
python_version: {type}`str` The Python version, in `major.minor.micro`
format.
python_bin: {type}`str` The path to the Python binary within the
Expand Down Expand Up @@ -78,6 +81,8 @@ def define_hermetic_runtime_toolchain_impl(
# During pyc creation, temp files named *.pyc.NNN are created
"**/__pycache__/*.pyc.*",
]
if interpreter_has_static_libpython:
files_exclude.append("lib/libpython*.so*")
files_exclude += extra_files_glob_exclude

native.filegroup(
Expand Down
28 changes: 28 additions & 0 deletions python/private/pbs_manifest.bzl
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Helper functions to parse python-build-standalone manifests."""

_ASTRAL_STATIC_LIBPYTHON_RELEASE = 20250517
_ASTRAL_RELEASE_URL_PREFIXES = [
"https://github.com/astral-sh/python-build-standalone/releases/download/",
"https://releases.astral.sh/github/python-build-standalone/releases/download/",
]

def parse_filename(filename):
"""Parses a python-build-standalone filename (or URL) into its components.

Expand Down Expand Up @@ -112,6 +118,28 @@ def parse_filename(filename):
"vendor": vendor,
}

# buildifier: disable=function-docstring-args
# buildifier: disable=function-docstring-return
# urls: list[str], release_filename: str -> bool
def is_astral_static_libpython_build(urls, release_filename):
"""Whether an Astral build includes libpython statically in its interpreter."""
parsed = parse_filename(release_filename)
if not parsed:
return False

build_version = parsed["build_version"]
if (
not build_version.isdigit() or
int(build_version) < _ASTRAL_STATIC_LIBPYTHON_RELEASE
):
return False

return any([
url.startswith(prefix)
for url in urls
for prefix in _ASTRAL_RELEASE_URL_PREFIXES
])

def parse_runtime_manifest(content):
"""Parses the SHA256SUMS file content into a list of structs.

Expand Down
26 changes: 23 additions & 3 deletions python/private/pypi/hub_builder.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,25 @@ def _pip_parse(self, module_ctx, pip_attr, python_version = None):
))
return

if (pip_attr.python_interpreter_target == None and
not pip_attr.python_interpreter and
_host_interpreter_name(python_version) not in self._available_interpreters):
# The version has a toolchain, but none of its interpreters runs on
# this host, e.g. there is no CPython build for this os and cpu. Treat
# it like a version missing from minor_mapping instead of failing the
# extension, which would also take down every other version and hub,
# including ones this host can use.
self._logger.info(lambda: (
"Ignoring pip python version '{version}' for hub " +
"'{hub}' in module '{module}' because there is no " +
"interpreter for it that runs on this host."
).format(
hub = self.name,
module = self.module_name,
version = python_version,
))
return

_set_get_index_urls(self, module_ctx, pip_attr)
self._platforms[python_version] = _platforms(
module_ctx,
Expand Down Expand Up @@ -395,12 +414,13 @@ def _set_get_index_urls(self, mctx, pip_attr):
)
return True

def _host_interpreter_name(python_version):
return "python_{}_host".format(python_version.replace(".", "_"))

def _detect_interpreter(self, pip_attr, python_version):
python_interpreter_target = pip_attr.python_interpreter_target
if python_interpreter_target == None and not pip_attr.python_interpreter:
python_name = "python_{}_host".format(
python_version.replace(".", "_"),
)
python_name = _host_interpreter_name(python_version)
if python_name not in self._available_interpreters:
fail((
"Unable to find interpreter for pip hub '{hub_name}' for " +
Expand Down
Loading
Loading