# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Editing Github Actions directly will fail in CI, instead edit this file and run:
# `pants run src/python/pants_release/generate_github_workflows.py`

from __future__ import annotations

import argparse
import difflib
import os
import re
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum, ReprEnum
from pathlib import Path
from textwrap import dedent  # noqa: PNT20
from typing import Any, cast

import toml
import yaml
from pants_release.common import die


def action(name: str) -> str:
    version_map = {
        "action-send-mail": "dawidd6/action-send-mail@v3.8.0",
        "actions-rust-lang": "actions-rust-lang/setup-rust-toolchain@v1",
        "attest-build-provenance": "actions/attest-build-provenance@v3",
        "cache": "actions/cache@v5",
        "checkout": "actions/checkout@v6",
        "coverallsapp": "coverallsapp/github-action@v2",
        "download-artifact": "actions/download-artifact@v7",
        "free-disk-space": "jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be",
        "github-action-required-labels": "mheap/github-action-required-labels@v4.0.0",
        "msys2": "msys2/setup-msys2@v2",
        "rust-cache": "Swatinem/rust-cache@v2.8.2",
        "setup-go": "actions/setup-go@v6",
        "setup-java": "actions/setup-java@v5",
        "setup-node": "actions/setup-node@v6",
        "setup-protoc": "arduino/setup-protoc@3ea1d70ac22caff0b66ed6cb37d5b7aadebd4623",
        "setup-python": "actions/setup-python@v6",
        "slack-github-action": "slackapi/slack-github-action@v2.1.1",
        "upload-artifact": "actions/upload-artifact@v6",
    }
    try:
        return version_map[name]
    except KeyError as e:
        raise ValueError(
            f"Requested github action '{name}' does not have a version configured.\n"
            f"Current known versions: {version_map}"
        ) from e


HEADER = dedent(
    """\
    # GENERATED, DO NOT EDIT!
    # To change, edit `src/python/pants_release/generate_github_workflows.py` and run:
    #   pants run src/python/pants_release/generate_github_workflows.py
    """
)

TEST_PYTHON_JOB_PREFIX = "test_python"


Step = dict[str, Any]
Jobs = dict[str, Any]
Env = dict[str, str]


class Platform(Enum):
    LINUX_X86_64 = "Linux-x86_64"
    LINUX_ARM64 = "Linux-ARM64"
    MACOS14_ARM64 = "macOS14-ARM64"
    WINDOWS11_X86_64 = "Windows11-x86_64"


GITHUB_HOSTED = {Platform.LINUX_X86_64, Platform.MACOS14_ARM64}
SELF_HOSTED = {Platform.LINUX_ARM64}
CARGO_AUDIT_IGNORED_ADVISORY_IDS = (
    "RUSTSEC-2020-0128",  # returns a false positive on the cache crate, which is a local crate not a 3rd party crate
)

# We don't specify a patch version so that we get the latest, which comes pre-installed:
#  https://github.com/actions/setup-python#available-versions-of-python
# NOTE: The last entry becomes the default
_BASE_PYTHON_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.12", "3.13", "3.11"]

PYTHON_VERSIONS_PER_PLATFORM = {
    Platform.LINUX_X86_64: _BASE_PYTHON_VERSIONS,
    # Python 3.7 or 3.8 aren't supported directly on arm64 macOS
    Platform.MACOS14_ARM64: [v for v in _BASE_PYTHON_VERSIONS if v not in ("3.7", "3.8")],
    # These runners have Python already installed
    Platform.LINUX_ARM64: None,
}


def gha_expr(expr: str) -> str:
    """Properly quote GitHub Actions expressions.

    Because we use f-strings often, but not always, in this script, it is very easy to get the
    quoting of the double curly braces wrong, especially when changing a non-f-string to an f-string
    or vice versa. So instead we universally delegate to this function.
    """
    # Here we use simple string concat instead of getting tangled up with escaping in f-strings.
    return "${{ " + expr + " }}"


def hash_files(path: str) -> str:
    """Generate a properly quoted hashFiles call for the given path."""
    return gha_expr(f"hashFiles('{path}')")


# ----------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------


# NB: The `upload-artifact` action strips the longest common prefix of paths in the
# created artifact, but the `download-artifact` action needs to know what that prefix
# was.
NATIVE_FILES_COMMON_PREFIX = "src/python/pants"
NATIVE_FILES = [
    f"{NATIVE_FILES_COMMON_PREFIX}/bin/native_client",
    f"{NATIVE_FILES_COMMON_PREFIX}/bin/sandboxer",
    f"{NATIVE_FILES_COMMON_PREFIX}/engine/internals/native_engine.so",
    f"{NATIVE_FILES_COMMON_PREFIX}/engine/internals/native_engine.so.metadata",
]

DONT_SKIP_RUST = "needs.classify_changes.outputs.rust == 'true'"
DONT_SKIP_WHEELS = "needs.classify_changes.outputs.release == 'true' || needs.classify_changes.outputs.ci_config == 'true'"
IS_PANTS_OWNER = "github.repository_owner == 'pantsbuild'"

# NB: This overrides `pants.ci.toml`.
DISABLE_REMOTE_CACHE_ENV = {"PANTS_REMOTE_CACHE_READ": "false", "PANTS_REMOTE_CACHE_WRITE": "false"}


# ----------------------------------------------------------------------
# Actions
# ----------------------------------------------------------------------


def classify_changes() -> Jobs:
    linux_x86_64_helper = Helper(Platform.LINUX_X86_64)
    return {
        "classify_changes": {
            "name": "Classify changes",
            "runs-on": linux_x86_64_helper.runs_on(),
            "if": IS_PANTS_OWNER,
            "outputs": {
                "dev_utils": gha_expr("steps.classify.outputs.dev_utils"),
                "docs": gha_expr("steps.classify.outputs.docs"),
                "rust": gha_expr("steps.classify.outputs.rust"),
                "release": gha_expr("steps.classify.outputs.release"),
                "ci_config": gha_expr("steps.classify.outputs.ci_config"),
                "notes": gha_expr("steps.classify.outputs.notes"),
                "other": gha_expr("steps.classify.outputs.other"),
                "no_code": gha_expr("steps.classify.outputs.no_code"),
            },
            "steps": [
                *checkout(),
                {
                    "id": "classify",
                    "name": "Classify changed files",
                    "run": dedent(
                        """\
                        if [[ -z $GITHUB_EVENT_PULL_REQUEST_BASE_SHA ]]; then
                          # push: compare to the immediate parent, which should already be fetched
                          # (checkout's fetch_depth defaults to 10)
                          comparison_sha=$(git rev-parse HEAD^)
                        else
                          # pull request: compare to the base branch, ensuring that commit exists
                          git fetch --depth=1 "$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
                          comparison_sha="$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
                        fi
                        echo "comparison_sha=$comparison_sha"

                        change_labels=$(git diff --name-only "$comparison_sha" HEAD | python build-support/bin/classify_changed_files.py)
                        echo "Change Labels:"
                        for i in ${change_labels}; do
                          echo "${i}=true" | tee -a $GITHUB_OUTPUT
                        done
                        """
                    ),
                },
            ],
        },
    }


def ensure_release_notes() -> Sequence[Step]:
    """Check that a PR either has release notes, or a category:internal or release-notes:not-
    required label."""
    return [
        {
            # If there's release note changes, then we're good to go and no need to check for one of
            # the opt-out labels. If there's not, then we should check to see if a human has opted
            # out via a label.
            "if": "github.event_name == 'pull_request' && !needs.classify_changes.outputs.notes",
            "name": "Ensure appropriate label",
            "uses": action("github-action-required-labels"),
            "env": {"GITHUB_TOKEN": gha_expr("secrets.GITHUB_TOKEN")},
            "with": {
                "mode": "minimum",
                "count": 1,
                "labels": "release-notes:not-required, category:internal",
                "message": dedent(
                    """
                    Please do one of:

                    - add release notes to the appropriate file in `docs/notes`

                    - label this PR with `release-notes:not-required` if it does not need them (for
                      instance, if this is fixing a minor typo in documentation)

                    - label this PR with `category:internal` if it's an internal change

                    Feel free to ask a maintainer for help if you are not sure what is appropriate!
                    """
                ),
            },
        },
    ]


def checkout(
    *,
    fetch_depth: int = 10,
    containerized: bool = False,
    ref: str | None = None,
    **extra_opts: object,
) -> Sequence[Step]:
    """Get prior commits and the commit message."""
    fetch_depth_opt: dict[str, Any] = {"fetch-depth": fetch_depth}
    steps = [
        # See https://github.community/t/accessing-commit-message-in-pull-request-event/17158/8
        # for details on how we get the commit message here.
        # We need to fetch a few commits back, to be able to access HEAD^2 in the PR case.
        {
            "name": "Check out code",
            "uses": action("checkout"),
            "with": {
                **fetch_depth_opt,
                **({"ref": ref} if ref else {}),
                **extra_opts,
            },
        },
    ]
    if containerized:
        steps.append(
            # Work around https://github.com/actions/checkout/issues/760 for our container jobs.
            # See:
            # + https://github.blog/2022-04-12-git-security-vulnerability-announced
            # + https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-24765
            {
                "name": "Configure Git",
                "run": 'git config --global safe.directory "$GITHUB_WORKSPACE"',
            }
        )
    return steps


def launch_bazel_remote() -> Sequence[Step]:
    """Run a sidecar bazel-remote instance.

    This process proxies to a public-read/private-write S3 bucket (cache.pantsbuild.org). PRs within
    pantsbuild/pants will have AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY secrets set and so will be
    able to read and write the cache. PRs across forks will not, so they use hard-coded read only
    creds so they can at least read from the cache.
    """
    return [
        {
            "name": "Launch bazel-remote",
            "run": dedent(
                """\
                mkdir -p ~/bazel-remote
                if [[ -z "${AWS_ACCESS_KEY_ID}" ]]; then
                  CACHE_WRITE=false
                  # If no secret read/write creds, use hard-coded read-only creds, so that
                  # cross-fork PRs can at least read from the cache.
                  # These creds are hard-coded here in this public repo, which makes the bucket
                  # world-readable. But since putting raw AWS tokens in a public repo, even
                  # deliberately, is icky, we base64-them. This will at least help hide from
                  # automated scanners that look for checked in AWS keys.
                  # Not that it would be terrible if we were scanned, since this is public
                  # on purpose, but it's best not to draw attention.
                  AWS_ACCESS_KEY_ID=$(echo 'QUtJQVY2QTZHN1JRVkJJUVM1RUEK' | base64 -d)
                  AWS_SECRET_ACCESS_KEY=$(echo 'd3dOQ1k1eHJJWVVtejZBblV6M0l1endXV0loQWZWcW9GZlVjMDlKRwo=' | base64 -d)
                else
                  CACHE_WRITE=true
                fi
                docker run --detach -u 1001:1000 \
                  -v ~/bazel-remote:/data \
                  -p 9092:9092 \
                  buchgr/bazel-remote-cache:v2.4.1 \
                  --s3.auth_method=access_key \
                  --s3.access_key_id="${AWS_ACCESS_KEY_ID}" \
                  --s3.secret_access_key="${AWS_SECRET_ACCESS_KEY}" \
                  --s3.bucket=cache.pantsbuild.org \
                  --s3.endpoint=s3.us-east-1.amazonaws.com \
                  --max_size 30
                echo "PANTS_REMOTE_STORE_ADDRESS=grpc://localhost:9092" >> "$GITHUB_ENV"
                echo "PANTS_REMOTE_CACHE_READ=true" >> "$GITHUB_ENV"
                echo "PANTS_REMOTE_CACHE_WRITE=${CACHE_WRITE}" >> "$GITHUB_ENV"
                """
            ),
            "env": {
                "AWS_ACCESS_KEY_ID": f"{gha_expr('secrets.AWS_ACCESS_KEY_ID')}",
                "AWS_SECRET_ACCESS_KEY": f"{gha_expr('secrets.AWS_SECRET_ACCESS_KEY')}",
            },
        }
    ]


def global_env() -> Env:
    return {
        "PANTS_CONFIG_FILES": "+['pants.ci.toml']",
        "PANTS_DISABLE_GETS": "1",
        "RUST_BACKTRACE": "all",
        # Default to disabling OpenTelemetry so GHA steps not using Pants directly do not try
        # to use Honeycomb if they do invoke Pants indirectly (e.g., Rust integration tests).
        # Needed because pants.ci.toml refers to `env.HONEYCOMB_API_KEY`.
        "PANTS_SHOALSOFT_OPENTELEMETRY_ENABLED": "False",
        "HONEYCOMB_API_KEY": "--DISABLED--",
    }


def rust_channel() -> str:
    with open("src/rust/rust-toolchain") as fp:
        rust_toolchain = toml.load(fp)
    return cast(str, rust_toolchain["toolchain"]["channel"])


def install_rustup() -> list[Step]:
    return [
        {
            "name": "Install rustup",
            "run": dedent(
                """\
            curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -v -y --default-toolchain none
            echo "${HOME}/.cargo/bin" >> $GITHUB_PATH
            """
            ),
        },
        {
            "name": "Install Rust toolchain",
            "run": dedent(
                f"""\
            # Set the default toolchain. Installs the toolchain if it is not already installed.
            rustup default {rust_channel()}
            cargo version
            """
            ),
        },
    ]


def install_pythons(versions: list[str]) -> Step:
    # See:
    # https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#specifying-multiple-pythonpypy-versions
    # This is a list expressed as a newline delimited string instead of a... list
    return {
        "name": f"Set up Python {', '.join(versions)}",
        "uses": action("setup-python"),
        "with": {"python-version": "\n".join(versions)},
    }


def install_node(version: str) -> Step:
    return {
        "name": f"Set up Node {version}",
        "uses": action("setup-node"),
        "with": {"node-version": version},
    }


def install_jdk() -> Step:
    return {
        "name": "Install AdoptJDK",
        "uses": action("setup-java"),
        "with": {
            "distribution": "adopt",
            "java-version": "11",
        },
    }


def install_go() -> list[Step]:
    def go_cfg(go_version: str) -> Step:
        return {
            "name": "Install Go",
            "uses": action("setup-go"),
            "with": {"go-version": go_version},
        }

    return [go_cfg(go_version) for go_version in ("1.25.3", "1.24.9")]


def install_python_headers_in_manylinux_container() -> Step:
    return {
        "name": "Install Python headers",
        "run": "yum install -y python3.11-devel",
    }


# NOTE: Any updates to the version of arduino/setup-protoc will require an audit of the updated  source code to verify
# nothing "bad" has been added to the action. (We pass the user's GitHub secret to the action in order to avoid the
# default GitHub rate limits when downloading protoc._
def install_protoc() -> Step:
    return {
        "name": "Install Protoc",
        "uses": action("setup-protoc"),
        "with": {
            "version": "23.x",
            "repo-token": "${{ secrets.GITHUB_TOKEN }}",
        },
    }


def download_apache_thrift() -> Step:
    return {
        "name": "Download Apache `thrift` binary (Linux)",
        "if": "runner.os == 'Linux'",
        "run": dedent(
            """\
            mkdir -p "${HOME}/.thrift"
            curl --fail -L https://binaries.pantsbuild.org/bin/thrift/linux/x86_64/0.15.0/thrift -o "${HOME}/.thrift/thrift"
            chmod +x "${HOME}/.thrift/thrift"
            echo "${HOME}/.thrift" >> $GITHUB_PATH
            """
        ),
    }


class Helper:
    def __init__(self, platform: Platform):
        self.platform = platform

    def platform_name(self) -> str:
        return str(self.platform.value)

    def job_name_suffix(self) -> str:
        return self.platform_name().lower().replace("-", "_")

    def job_name(self, prefix: str, shard: str | None = None) -> str:
        name = f"{prefix}_{self.job_name_suffix()}"
        if shard:
            name += f"_{shard}"
        return name

    def runs_on(self) -> list[str]:
        # GHA strongly recommends targeting the self-hosted label as well as
        # any platform-specific labels, so we don't run on future GH-hosted
        # platforms without realizing it.
        ret = ["self-hosted"] if self.platform in SELF_HOSTED else []
        if self.platform == Platform.MACOS14_ARM64:
            ret += ["macos-14"]
        elif self.platform == Platform.LINUX_X86_64:
            ret += ["ubuntu-22.04"]
        elif self.platform == Platform.LINUX_ARM64:
            ret += [
                "runs-on",
                "runner=4cpu-linux-arm64",
                "image=ubuntu22-full-arm64-python3.7-3.13",
                "run-id=${{ github.run_id }}",
            ]
        elif self.platform == Platform.WINDOWS11_X86_64:
            ret += ["windows-2025"]
        else:
            raise ValueError(f"Unsupported platform: {self.platform_name()}")
        return ret

    def platform_env(self):
        ret = {}
        if self.platform in {Platform.MACOS14_ARM64}:
            ret["ARCHFLAGS"] = "-arch arm64"
            ret["_PYTHON_HOST_PLATFORM"] = "macosx-14.0-arm64"
        if self.platform == Platform.LINUX_X86_64:
            # Currently we run Linux x86_64 CI on GitHub Actions-hosted hardware, and
            # these are weak dual-core machines. Default parallelism on those machines
            # leads to many test timeouts. This parallelism reduction appears to lead
            # to test shard runs that are 50% slower on average, but more likely to
            # complete without timeouts.
            # TODO: If we add a "redo timed out tests" feature, we can kill this.
            ret["PANTS_PROCESS_EXECUTION_LOCAL_PARALLELISM"] = "1"
        return ret

    def wrap_cmd(self, cmd: str) -> str:
        return cmd

    def native_binaries_upload(self) -> Step:
        return {
            "name": "Upload native binaries",
            "uses": action("upload-artifact"),
            "with": {
                "name": f"native_binaries.{gha_expr('matrix.python-version')}.{self.platform_name()}",
                "path": "\n".join(NATIVE_FILES),
            },
        }

    def native_binaries_download(self) -> Sequence[Step]:
        return [
            {
                "name": "Download native binaries",
                "uses": action("download-artifact"),
                "with": {
                    "name": f"native_binaries.{gha_expr('matrix.python-version')}.{self.platform_name()}",
                    "path": NATIVE_FILES_COMMON_PREFIX,
                },
            },
            {
                "name": "Make native-client runnable",
                "run": f"chmod +x {NATIVE_FILES[0]}",
            },
        ]

    def rust_caches(self) -> Sequence[Step]:
        return [
            install_protoc(),  # for `prost` crate
            {
                "name": "Set rustup profile",
                "run": "rustup set profile default",
            },
            {
                "name": "Cache Rust toolchain",
                "uses": action("cache"),
                "with": {
                    "path": f"~/.rustup/toolchains/{rust_channel()}-*\n~/.rustup/update-hashes\n~/.rustup/settings.toml\n",
                    "key": f"{self.platform_name()}-rustup-{hash_files('src/rust/rust-toolchain')}-v2",
                },
            },
            {
                "name": "Cache Cargo",
                "uses": action("rust-cache"),
                "with": {
                    # If set, replaces the job id in the cache key, so that the cache is stable across jobs.
                    # If we don't set this, each job may restore from a previous job's cache entry (via a
                    # restore key) but will write its own entry, even if there were no rust changes.
                    # This will cause us to hit the 10GB limit much sooner, and also spend time uploading
                    # identical cache entries unnecessarily.
                    "shared-key": "engine",
                    "workspaces": "src/rust",
                    # A custom option from our fork of the action.
                    "cache-bin": "false",
                },
            },
        ]

    def bootstrap_caches(self) -> Sequence[Step]:
        return [
            *self.rust_caches(),
            # NB: This caching is only intended for the bootstrap jobs to avoid them needing to
            # re-compile when possible. Compare to the upload-artifact and download-artifact actions,
            # which are how the bootstrap jobs share the compiled binaries with the other jobs like
            # `lint` and `test`.
            {
                "name": "Get native engine hash",
                "id": "get-engine-hash",
                "run": 'echo "hash=$(./build-support/bin/rust/print_engine_hash.sh)" >> $GITHUB_OUTPUT',
                "shell": "bash",
            },
            {
                "name": "Cache native engine",
                "uses": action("cache"),
                "with": {
                    "path": "\n".join(NATIVE_FILES),
                    "key": f"{self.platform_name()}-engine-{gha_expr('steps.get-engine-hash.outputs.hash')}-v1",
                },
            },
        ]

    def setup_pythons(self) -> Sequence[Step]:
        ret = []
        versions = PYTHON_VERSIONS_PER_PLATFORM[self.platform]
        if versions is not None:
            ret.append(install_pythons(versions))
        return ret

    def bootstrap_pants(self) -> Sequence[Step]:
        return [
            free_disk_space_step(),
            *checkout(),
            *self.setup_pythons(),
            *self.bootstrap_caches(),
            {
                "name": "Bootstrap Pants",
                # Check for a regression of https://github.com/pantsbuild/pants/issues/17470.
                "run": self.wrap_cmd(
                    f"./pants version > {gha_expr('runner.temp')}/_pants_version.stdout && "
                    f"[[ -s {gha_expr('runner.temp')}/_pants_version.stdout ]]"
                ),
            },
            {
                "name": "Run smoke tests",
                "run": dedent(
                    f"""\
                    {self.wrap_cmd("./pants list ::")}
                    {self.wrap_cmd("./pants roots")}
                    {self.wrap_cmd("./pants help goals")}
                    {self.wrap_cmd("./pants help targets")}
                    {self.wrap_cmd("./pants help subsystems")}
                    """
                ),
            },
            self.upload_log_artifacts(name="bootstrap"),
            self.native_binaries_upload(),
        ]

    def upload_log_artifacts(self, name: str) -> Step:
        return {
            "name": "Upload pants.log",
            "uses": action("upload-artifact"),
            "if": "always()",
            "continue-on-error": True,
            "with": {
                "name": f"logs-{name.replace('/', '_')}-{self.platform_name()}",
                "path": ".pants.d/workdir/*.log",
                "overwrite": "true",
            },
        }

    def upload_test_reports(self) -> Step:
        # The path doesn't include job ID, as we want to aggregate test reports across all
        # jobs/shards in a workflow.  We do, however, qualify by run attempt, so we capture
        # separate reports for tests that flake between attempts on the same workflow run.
        s3_dst = (
            "s3://logs.pantsbuild.org/test/reports/"
            + self.platform_name()
            + "/"
            + "$(git show --no-patch --format=%cd --date=format:%Y-%m-%d)/"
            + "${GITHUB_REF_NAME//\\//_}/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${GITHUB_JOB}"
        )
        return {
            "name": "Upload test reports",
            "if": "always()",
            "continue-on-error": True,
            "run": dedent(
                f"""\
                export S3_DST={s3_dst}
                echo "Uploading test reports to ${{S3_DST}}"
                ./pants run ./src/python/pants_release/copy_to_s3.py \
                  -- \
                  --src-prefix=dist/test/reports \
                  --dst-prefix=${{S3_DST}} \
                  --path=""
                """
            ),
            "env": {
                "AWS_SECRET_ACCESS_KEY": f"{gha_expr('secrets.AWS_SECRET_ACCESS_KEY')}",
                "AWS_ACCESS_KEY_ID": f"{gha_expr('secrets.AWS_ACCESS_KEY_ID')}",
            },
        }

    def coveralls_report(self, flag: str) -> Step:
        return {
            "name": "Report coverage to coveralls.io",
            "uses": action("coverallsapp"),
            "if": "always()",
            "continue-on-error": True,
            "with": {
                "flag-name": flag,
                "parallel": True,
                "file": "dist/coverage/python/coverage.xml",
                "format": "cobertura",
                "allow-empty": True,
                "fail-on-error": False,
            },
        }


def free_disk_space_step() -> Step:
    return {
        "name": "Free up disk space",
        "uses": action("free-disk-space"),
        "with": {
            "android": True,  # ~9GB
            "dotnet": True,  # ~4GB
            "haskell": True,  # ~6GB
            # Keep the tool cache since we use other actions which install to the tool cache.
            "tool-cache": False,
            # Disable these because not as huge an impact or for `large-packages` it would take time to remove packages
            # since `apt-get` must be invoked.
            "large-packages": False,
            "docker-images": False,
            "swap-storage": False,
        },
    }


# A greatly simplified limited version for use when running in a container
def free_disk_space_in_container(host_root_mount: str) -> Step:
    return {
        "name": "Free up disk space",
        "run": "\n".join(
            [
                "df -h",
                f"rm -rf {host_root_mount}/usr/share/dotnet || true",
                f"rm -rf {host_root_mount}/usr/local/lib/android || true",
                f"rm -rf {host_root_mount}/opt/ghc || true",
                f"rm -rf {host_root_mount}/usr/local/.ghcup || true",
                "df -h",
            ]
        ),
    }


class RustTesting(Enum):
    NONE = "NONE"
    SOME = "SOME"  # Most tests.
    ALL = "ALL"  # All tests, lint and bench.


def bootstrap_jobs(
    helper: Helper,
    validate_ci_config: bool,
    rust_testing: RustTesting,
) -> Jobs:
    human_readable_job_name = "Bootstrap Pants"

    if rust_testing == RustTesting.NONE:
        human_readable_step_name = ""
        step_cmd = ""
    elif rust_testing == RustTesting.SOME:
        human_readable_job_name += ", test Rust"
        human_readable_step_name = "Test Rust"
        # We pass --tests to skip doc tests because our generated protos contain
        # invalid doc tests in their comments. We do not pass --all as BRFS tests don't
        # pass on GHA MacOS containers.
        step_cmd = helper.wrap_cmd("./cargo test --locked --tests -- --nocapture")
    elif rust_testing == RustTesting.ALL:
        human_readable_job_name += ", test and lint Rust"
        human_readable_step_name = "Test and lint Rust"
        # We pass --tests to skip doc tests because our generated protos contain
        # invalid doc tests in their comments, and --benches to ensure that the
        # benchmarks can at least execute once correctly
        step_cmd = "\n".join(
            [
                "./build-support/bin/check_rust_pre_commit.sh",
                "./cargo test --locked --all --tests --benches -- --nocapture",
            ]
        )
    else:
        raise ValueError(f"Unrecognized RustTesting value: {rust_testing}")

    if helper.platform in [Platform.LINUX_X86_64]:
        step_cmd = "sudo apt-get install -y pkg-config fuse libfuse-dev\n" + step_cmd
    human_readable_job_name += f" ({helper.platform_name()})"

    return {
        "name": human_readable_job_name,
        "runs-on": helper.runs_on(),
        "env": DISABLE_REMOTE_CACHE_ENV,
        "timeout-minutes": 60,
        "if": IS_PANTS_OWNER,
        "steps": [
            *helper.bootstrap_pants(),
            *(
                [
                    {
                        "name": "Validate CI config",
                        "run": dedent(
                            """\
                    ./pants run src/python/pants_release/generate_github_workflows.py -- --check
                    """
                        ),
                    }
                ]
                if validate_ci_config
                else []
            ),
            *(
                [
                    {
                        "name": human_readable_step_name,
                        # We pass --tests to skip doc tests because our generated protos contain
                        # invalid doc tests in their comments.
                        "run": step_cmd,
                        "env": {
                            "TMPDIR": f"{gha_expr('runner.temp')}",
                        },
                        "if": DONT_SKIP_RUST,
                    }
                ]
                if human_readable_step_name
                else []
            ),
        ],
    }


def test_jobs(
    helper: Helper, shard: str | None, platform_specific: bool, with_remote_caching: bool
) -> Jobs:
    human_readable_job_name = f"Test Python ({helper.platform_name()})"
    human_readable_step_name = "Run Python tests"
    log_name = "python-test"
    pants_args = ["test"]
    if shard:
        human_readable_job_name += f" Shard {shard}"
        human_readable_step_name = f"Run Python test shard {shard}"
        log_name += f"-{shard}"
        pants_args.append(f"--shard={shard}")
    pants_args.append("::")
    if platform_specific:
        pants_args = (
            ["--tag=+platform_specific_behavior"]
            + pants_args
            + ["--", "-m", "platform_specific_behavior"]
        )
    pants_args = ["./pants"] + pants_args
    pants_args_str = " ".join(pants_args) + "\n"

    return {
        "name": human_readable_job_name,
        "runs-on": helper.runs_on(),
        "needs": helper.job_name("bootstrap_pants"),
        "env": helper.platform_env(),
        "timeout-minutes": 90,
        "if": IS_PANTS_OWNER,
        "steps": [
            free_disk_space_step(),
            *checkout(),
            *(launch_bazel_remote() if with_remote_caching else []),
            install_jdk(),
            *install_go(),
            *(
                [download_apache_thrift()]
                if helper.platform == Platform.LINUX_X86_64
                # Other platforms either don't run those tests, or have the binaries
                # preinstalled on the self-hosted runners.
                else []
            ),
            *helper.setup_pythons(),
            *helper.native_binaries_download(),
            {
                "name": human_readable_step_name,
                "run": pants_args_str,
            },
            helper.upload_test_reports(),
            helper.upload_log_artifacts(name=log_name),
            helper.coveralls_report(flag=helper.job_name(TEST_PYTHON_JOB_PREFIX, shard)),
        ],
    }


def linux_x86_64_test_jobs() -> Jobs:
    helper = Helper(Platform.LINUX_X86_64)

    def test_python_linux(shard: str) -> dict[str, Any]:
        return test_jobs(helper, shard, platform_specific=False, with_remote_caching=True)

    jobs = {
        helper.job_name("bootstrap_pants"): bootstrap_jobs(
            helper, validate_ci_config=True, rust_testing=RustTesting.ALL
        ),
