From b3dde9591e583d845abf8152d3323182e2f84f33 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Tue, 7 Jul 2026 15:03:59 -0700 Subject: [PATCH 01/11] sklearn: add sagemaker e2e tests + dispatch-release workflow --- .../workflows/sklearn.dispatch-release.yml | 25 ++ .github/workflows/sklearn.pipeline.yml | 15 +- .github/workflows/sklearn.tests-integ.yml | 90 ++++++ test/sklearn/__init__.py | 0 test/sklearn/conftest.py | 22 ++ test/sklearn/e2e/__init__.py | 0 test/sklearn/e2e/conftest.py | 267 ++++++++++++++++++ test/sklearn/e2e/test_inference.py | 47 +++ test/sklearn/e2e/test_inference_mme.py | 75 +++++ test/sklearn/e2e/test_network_isolation.py | 30 ++ test/sklearn/e2e/test_scoring_pipelines.py | 81 ++++++ test/sklearn/e2e/test_script_mode_e2e.py | 26 ++ test/sklearn/requirements.txt | 1 + 13 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sklearn.dispatch-release.yml create mode 100644 .github/workflows/sklearn.tests-integ.yml create mode 100644 test/sklearn/__init__.py create mode 100644 test/sklearn/conftest.py create mode 100644 test/sklearn/e2e/__init__.py create mode 100644 test/sklearn/e2e/conftest.py create mode 100644 test/sklearn/e2e/test_inference.py create mode 100644 test/sklearn/e2e/test_inference_mme.py create mode 100644 test/sklearn/e2e/test_network_isolation.py create mode 100644 test/sklearn/e2e/test_scoring_pipelines.py create mode 100644 test/sklearn/e2e/test_script_mode_e2e.py create mode 100644 test/sklearn/requirements.txt diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.dispatch-release.yml new file mode 100644 index 000000000000..6746155e66cf --- /dev/null +++ b/.github/workflows/sklearn.dispatch-release.yml @@ -0,0 +1,25 @@ +name: "Dispatch - Scikit-learn Release" + +on: + workflow_dispatch: + inputs: + config-file: + description: "Path to image config YAML (e.g., .github/config/image/sklearn/sagemaker-py312.yml)" + required: true + type: string + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + pipeline: + uses: ./.github/workflows/sklearn.pipeline.yml + with: + config-file: ${{ inputs.config-file }} + tag-suffix: ${{ github.run_id }} + release: true + secrets: inherit diff --git a/.github/workflows/sklearn.pipeline.yml b/.github/workflows/sklearn.pipeline.yml index a2576848679d..5fad709e95fd 100644 --- a/.github/workflows/sklearn.pipeline.yml +++ b/.github/workflows/sklearn.pipeline.yml @@ -131,6 +131,19 @@ jobs: config-file: ${{ inputs.config-file }} image-uri: ${{ needs.build.outputs.image-uri || '' }} + # Full integration suite — SageMaker training/endpoint E2E. + # Heavy; only runs on the release pipeline, not on PRs. + integ-test-full: + if: ${{ always() && !failure() && !cancelled() && inputs.release }} + needs: [build] + concurrency: + group: ${{ github.workflow }}-integ-test-full-${{ inputs.config-file }}-${{ github.ref }} + cancel-in-progress: true + uses: ./.github/workflows/sklearn.tests-integ.yml + with: + config-file: ${{ inputs.config-file }} + image-uri: ${{ needs.build.outputs.image-uri || '' }} + release-gate: if: >- github.ref == 'refs/heads/main' && @@ -139,7 +152,7 @@ jobs: inputs.release && !failure() && !cancelled() && needs.build.result == 'success' - needs: [build, sanity-test, security-test, unit-test, integ-test] + needs: [build, sanity-test, security-test, unit-test, integ-test, integ-test-full] runs-on: ubuntu-latest outputs: environment: ${{ steps.check.outputs.environment }} diff --git a/.github/workflows/sklearn.tests-integ.yml b/.github/workflows/sklearn.tests-integ.yml new file mode 100644 index 000000000000..0df82345b08d --- /dev/null +++ b/.github/workflows/sklearn.tests-integ.yml @@ -0,0 +1,90 @@ +name: Reusable Scikit-learn Integration Tests + +on: + workflow_call: + inputs: + config-file: + description: "Path to image config YAML" + required: true + type: string + image-uri: + description: "Image URI to test. If empty, derives prod URI from config." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + preflight: + runs-on: + - codebuild-runner-${{ github.run_id }}-${{ github.run_attempt }} + fleet:default-runner + buildspec-override:true + outputs: + exists: ${{ steps.check.outputs.exists }} + image-uri: ${{ steps.image.outputs.image-uri }} + aws-account-id: ${{ steps.image.outputs.aws-account-id }} + framework-version: ${{ steps.config.outputs.framework-version }} + steps: + - uses: actions/checkout@v6 + + - name: Resolve image URI + id: image + uses: ./.github/actions/resolve-image-uri + with: + image-uri: ${{ inputs.image-uri }} + ci-aws-account-id: ${{ vars.CI_AWS_ACCOUNT_ID }} + prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID }} + aws-region: ${{ vars.AWS_REGION }} + config-file: ${{ inputs.config-file }} + + - name: Check image exists + id: check + uses: ./.github/actions/check-image-exists + with: + image-uri: ${{ steps.image.outputs.image-uri }} + + - name: Parse config + id: config + run: | + CONFIG_FILE="${{ inputs.config-file }}" + echo "framework-version=$(yq '.metadata.framework_version' "$CONFIG_FILE")" >> $GITHUB_OUTPUT + + # SageMaker E2E tests — real training jobs + endpoints + e2e-test: + if: ${{ needs.preflight.outputs.exists == 'true' }} + needs: [preflight] + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + test-module: + - test_inference + - test_inference_mme + - test_network_isolation + - test_scoring_pipelines + - test_script_mode_e2e + runs-on: + - codebuild-runner-${{ github.run_id }}-${{ github.run_attempt }} + fleet:default-runner + buildspec-override:true + steps: + - uses: actions/checkout@v6 + + - name: Install test dependencies + run: | + uv venv --python 3.12 + source .venv/bin/activate + uv pip install -r test/requirements.txt + uv pip install -r test/sklearn/requirements.txt + + - name: Run ${{ matrix.test-module }} + run: | + source .venv/bin/activate + cd test/ + python3 -m pytest -v --tb=short -rA --log-cli-level=INFO \ + --image-uri ${{ needs.preflight.outputs.image-uri }} \ + --sklearn-version ${{ needs.preflight.outputs.framework-version }} \ + sklearn/e2e/${{ matrix.test-module }}.py diff --git a/test/sklearn/__init__.py b/test/sklearn/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/sklearn/conftest.py b/test/sklearn/conftest.py new file mode 100644 index 000000000000..055e2f2e670e --- /dev/null +++ b/test/sklearn/conftest.py @@ -0,0 +1,22 @@ +"""Shared pytest configuration for Scikit-learn tests.""" + +import pytest +from test_utils.constants import SAGEMAKER_ROLE + + +def pytest_addoption(parser): + parser.addoption( + "--sklearn-version", + default="1.4.2", + help="Scikit-learn version under test (e.g. 1.4.2)", + ) + + +@pytest.fixture(scope="session") +def sklearn_version(request): + return request.config.getoption("--sklearn-version") + + +@pytest.fixture(scope="session") +def role(): + return SAGEMAKER_ROLE diff --git a/test/sklearn/e2e/__init__.py b/test/sklearn/e2e/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/sklearn/e2e/conftest.py b/test/sklearn/e2e/conftest.py new file mode 100644 index 000000000000..f8d61730282a --- /dev/null +++ b/test/sklearn/e2e/conftest.py @@ -0,0 +1,267 @@ +"""Shared fixtures and helpers for Scikit-learn SageMaker E2E tests.""" + +import logging +import time + +import boto3 +import pytest +from sagemaker.estimator import Estimator +from sagemaker.inputs import TrainingInput +from sagemaker.model import Model +from sagemaker.multidatamodel import MultiDataModel +from sagemaker.pipeline import PipelineModel +from test_utils import random_suffix_name + +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.INFO) + +E2E_TEST_BUCKET = "amazonai-algorithms-integration-tests" +E2E_DATA_PREFIX = "input/scikit-learn" + +_created_models = [] +_created_endpoints = [] + + +def s3_uri(bucket, key): + return f"s3://{bucket}/{key}" + + +def data_uri(key): + return s3_uri(E2E_TEST_BUCKET, f"{E2E_DATA_PREFIX}/{key}") + + +def cleanup_resources(): + """Delete all SageMaker resources created during the test session.""" + sm = boto3.client("sagemaker") + for ep in _created_endpoints: + try: + sm.delete_endpoint(EndpointName=ep) + except Exception: + pass + try: + sm.delete_endpoint_config(EndpointConfigName=ep) + except Exception: + pass + for model_name in _created_models: + try: + sm.delete_model(ModelName=model_name) + except Exception: + pass + _created_endpoints.clear() + _created_models.clear() + + +@pytest.fixture(autouse=True, scope="session") +def _cleanup_after_session(): + """Automatically clean up all SageMaker resources after the test session.""" + yield + cleanup_resources() + + +def run_training_job( + image_uri, + role, + hyperparameters, + train_s3_key, + content_type=None, + test_name="train", + instance_type="ml.m5.xlarge", + instance_count=1, + volume_size=10, + max_run=1800, + input_mode="File", + train_distribution="ShardedByS3Key", + enable_network_isolation=False, + extra_channels=None, +): + """Launch a SageMaker training job and return (job_name, duration, description).""" + job_name = random_suffix_name(f"skl-{test_name}", 32) + output_path = s3_uri(E2E_TEST_BUCKET, f"e2e-output/{job_name}") + + estimator = Estimator( + image_uri=image_uri, + role=role, + instance_count=instance_count, + instance_type=instance_type, + output_path=output_path, + hyperparameters=hyperparameters, + volume_size=volume_size, + max_run=max_run, + input_mode=input_mode, + enable_network_isolation=enable_network_isolation, + ) + + train_input_kwargs = {"distribution": train_distribution} + if content_type is not None: + train_input_kwargs["content_type"] = content_type + channels = { + "train": TrainingInput( + s3_data=data_uri(train_s3_key), + **train_input_kwargs, + ), + } + + if extra_channels: + for name, value in extra_channels.items(): + if isinstance(value, tuple): + uri, ch_content_type = value + channels[name] = TrainingInput(s3_data=uri, content_type=ch_content_type) + else: + channels[name] = TrainingInput(s3_data=value) + + LOGGER.info(f"Starting job: {job_name} ({instance_count}x {instance_type})") + sm = boto3.client("sagemaker") + start = time.time() + try: + estimator.fit(channels, job_name=job_name) + except Exception: + try: + sm.stop_training_job(TrainingJobName=job_name) + except Exception: + pass + raise + duration = time.time() - start + + desc = sm.describe_training_job(TrainingJobName=job_name) + LOGGER.info( + f"Job {job_name} completed in {duration:.0f}s — status: {desc['TrainingJobStatus']}" + ) + return job_name, duration, desc + + +def deploy_endpoint( + image_uri, role, model_data, test_name="ep", instance_type="ml.m5.xlarge", env=None +): + """Deploy a real-time endpoint and return (predictor, endpoint_name).""" + from sagemaker.predictor import Predictor + + endpoint_name = random_suffix_name(f"skl-{test_name}", 32) + model = Model( + image_uri=image_uri, + model_data=model_data, + role=role, + env=env, + ) + try: + model.deploy( + initial_instance_count=1, + instance_type=instance_type, + endpoint_name=endpoint_name, + ) + except Exception: + if model.name: + _created_models.append(model.name) + raise + _created_models.append(model.name) + _created_endpoints.append(endpoint_name) + predictor = Predictor(endpoint_name=endpoint_name) + return predictor, endpoint_name + + +def deploy_multi_model_endpoint( + image_uri, + role, + model_data_prefix, + test_name="mme", + instance_type="ml.m5.xlarge", + env=None, +): + """Deploy a SageMaker Multi-Model Endpoint. + + `model_data_prefix` is the S3 prefix where model tarballs live + (each tarball becomes a target model addressable by name). + Returns (predictor, endpoint_name, mm_model). + """ + from sagemaker.predictor import Predictor + + endpoint_name = random_suffix_name(f"skl-{test_name}", 32) + mm_model = MultiDataModel( + name=random_suffix_name(f"skl-{test_name}-model", 32), + model_data_prefix=model_data_prefix, + image_uri=image_uri, + role=role, + env=env, + ) + try: + mm_model.deploy( + initial_instance_count=1, + instance_type=instance_type, + endpoint_name=endpoint_name, + ) + except Exception: + if mm_model.name: + _created_models.append(mm_model.name) + raise + _created_models.append(mm_model.name) + _created_endpoints.append(endpoint_name) + predictor = Predictor(endpoint_name=endpoint_name) + return predictor, endpoint_name, mm_model + + +def deploy_inference_pipeline( + models, role, test_name="pipe", instance_type="ml.m5.xlarge" +): + """Deploy a multi-container inference pipeline endpoint. + + `models` is an ordered list of (image_uri, model_data, env) tuples. + Returns (predictor, endpoint_name). + """ + from sagemaker.predictor import Predictor + + endpoint_name = random_suffix_name(f"skl-{test_name}", 32) + pipeline_models = [ + Model(image_uri=img, model_data=data, role=role, env=env) + for img, data, env in models + ] + pipeline = PipelineModel( + name=random_suffix_name(f"skl-{test_name}-model", 32), + role=role, + models=pipeline_models, + ) + try: + pipeline.deploy( + initial_instance_count=1, + instance_type=instance_type, + endpoint_name=endpoint_name, + ) + except Exception: + for m in pipeline_models: + if m.name: + _created_models.append(m.name) + raise + for m in pipeline_models: + if m.name: + _created_models.append(m.name) + _created_endpoints.append(endpoint_name) + predictor = Predictor(endpoint_name=endpoint_name) + return predictor, endpoint_name + + +def delete_endpoint(endpoint_name): + """Delete endpoint, endpoint config, and associated model(s).""" + sm = boto3.client("sagemaker") + try: + ep_config = sm.describe_endpoint_config(EndpointConfigName=endpoint_name) + for variant in ep_config.get("ProductionVariants", []): + model_name = variant.get("ModelName") + if model_name: + try: + sm.delete_model(ModelName=model_name) + except Exception: + pass + if model_name in _created_models: + _created_models.remove(model_name) + except Exception: + pass + try: + sm.delete_endpoint(EndpointName=endpoint_name) + except Exception: + pass + try: + sm.delete_endpoint_config(EndpointConfigName=endpoint_name) + except Exception: + pass + if endpoint_name in _created_endpoints: + _created_endpoints.remove(endpoint_name) + + diff --git a/test/sklearn/e2e/test_inference.py b/test/sklearn/e2e/test_inference.py new file mode 100644 index 000000000000..f7d3525f81f2 --- /dev/null +++ b/test/sklearn/e2e/test_inference.py @@ -0,0 +1,47 @@ +"""Online inference test — deploy an endpoint with a no-op echo model, +send a JSON payload, verify a response comes back. +""" + +import json + +import pytest + +from .conftest import data_uri, delete_endpoint, deploy_endpoint + + +ECHO_MODEL_KEY = "model/empty.tar.gz" +ECHO_CODE_KEY = "code/echo-2.4.10.tar.gz" + + +def _payload(feature_dim=100): + return json.dumps( + {"instances": [{"data": {"features": {"values": list(range(feature_dim))}}}]} + ) + + +@pytest.fixture(scope="module") +def echo_env(): + return { + "SAGEMAKER_PROGRAM": "echo.py", + "SAGEMAKER_SUBMIT_DIRECTORY": data_uri(ECHO_CODE_KEY), + } + + +class TestInference: + def test_json_inference(self, image_uri, role, echo_env): + endpoint_name = None + try: + predictor, endpoint_name = deploy_endpoint( + image_uri=image_uri, + role=role, + model_data=data_uri(ECHO_MODEL_KEY), + env=echo_env, + test_name="inference-solo", + ) + predictor.content_type = "application/json" + predictor.accept = "application/json" + response = predictor.predict(_payload()) + assert response is not None + finally: + if endpoint_name: + delete_endpoint(endpoint_name) diff --git a/test/sklearn/e2e/test_inference_mme.py b/test/sklearn/e2e/test_inference_mme.py new file mode 100644 index 000000000000..e65b6e13a2ea --- /dev/null +++ b/test/sklearn/e2e/test_inference_mme.py @@ -0,0 +1,75 @@ +"""Inference tests — both single-model and multi-model endpoints against the +sklearn MME model tarballs. Verifies both endpoint modes round-trip a +CSV payload. +""" + +import pytest + +from .conftest import ( + data_uri, + delete_endpoint, + deploy_endpoint, + deploy_multi_model_endpoint, +) + + +# MME lazy-loads only the tarball named in `target_model`, so the sibling +# `code/user_code.tar.gz` under this prefix is never invoked as a model. +MME_MODEL_PREFIX = "mme_models/" +MME_TARGET_MODELS = ["sklearn_1_model_0.tar.gz", "sklearn_1_model_1.tar.gz"] +MME_CODE_KEY = "mme_models/code/user_code.tar.gz" + +# Fitted models have n_features_in_ = 6. Multi-row CSV exercises batch predict. +SAMPLE_PAYLOAD = "\n".join( + [ + "0.0, 0.0, 0.0, 0.0, 0.0, 0.0", + "0.0, 2.0, 4.0, 6.0, 9.0, 3.0", + ] +) + + +@pytest.fixture(scope="module") +def mme_env(): + return { + "SAGEMAKER_PROGRAM": "script.py", + "SAGEMAKER_SUBMIT_DIRECTORY": data_uri(MME_CODE_KEY), + } + + +class TestInferenceMME: + def test_csv_single_model(self, image_uri, role, mme_env): + endpoint_name = None + try: + predictor, endpoint_name = deploy_endpoint( + image_uri=image_uri, + role=role, + model_data=data_uri(f"{MME_MODEL_PREFIX}{MME_TARGET_MODELS[0]}"), + env=mme_env, + test_name="inference-single", + ) + predictor.content_type = "text/csv" + predictor.accept = "text/csv" + response = predictor.predict(SAMPLE_PAYLOAD) + assert response is not None + finally: + if endpoint_name: + delete_endpoint(endpoint_name) + + def test_csv_multimodel(self, image_uri, role, mme_env): + endpoint_name = None + try: + predictor, endpoint_name, _ = deploy_multi_model_endpoint( + image_uri=image_uri, + role=role, + model_data_prefix=data_uri(MME_MODEL_PREFIX), + env=mme_env, + test_name="inference-mme", + ) + predictor.content_type = "text/csv" + predictor.accept = "text/csv" + for target_model in MME_TARGET_MODELS: + response = predictor.predict(SAMPLE_PAYLOAD, target_model=target_model) + assert response is not None + finally: + if endpoint_name: + delete_endpoint(endpoint_name) diff --git a/test/sklearn/e2e/test_network_isolation.py b/test/sklearn/e2e/test_network_isolation.py new file mode 100644 index 000000000000..5be7fa0d3ce3 --- /dev/null +++ b/test/sklearn/e2e/test_network_isolation.py @@ -0,0 +1,30 @@ +"""Network isolation training test — parquet training via script mode +with `enable_network_isolation=True`. Verifies the container completes training +without needing external network access. +""" + +from .conftest import data_uri, run_training_job + + +CODE_TARBALL_KEY = "code/pandas-parquet-file-1.4-2.tar.gz" +CODE_TARBALL_LOCAL = "/opt/ml/input/data/code/pandas-parquet-file-1.4-2.tar.gz" + + +class TestNetworkIsolation: + def test_script_mode(self, image_uri, role): + hp = { + "sagemaker_program": "train.py", + "sagemaker_submit_directory": CODE_TARBALL_LOCAL, + } + _, _, desc = run_training_job( + image_uri=image_uri, + role=role, + hyperparameters=hp, + train_s3_key="data/train.parquet", + content_type="application/x-parquet", + test_name="netiso", + volume_size=20, + enable_network_isolation=True, + extra_channels={"code": (data_uri(CODE_TARBALL_KEY), "text/plain")}, + ) + assert desc["TrainingJobStatus"] == "Completed" diff --git a/test/sklearn/e2e/test_scoring_pipelines.py b/test/sklearn/e2e/test_scoring_pipelines.py new file mode 100644 index 000000000000..48914da674cd --- /dev/null +++ b/test/sklearn/e2e/test_scoring_pipelines.py @@ -0,0 +1,81 @@ +"""Inference-pipeline endpoint tests — deploy sklearn + linear-learner in an +inference pipeline (both directions), verify a JSON payload round-trips. +""" + +import json + +import pytest + +from .conftest import ( + E2E_TEST_BUCKET, + data_uri, + delete_endpoint, + deploy_inference_pipeline, + s3_uri, +) + + +# us-west-2 SageMaker 1P algo ECR account for linear-learner +LINEAR_LEARNER_IMAGE = "174872318107.dkr.ecr.us-west-2.amazonaws.com/linear-learner:latest" + +SKLEARN_MODEL_KEY = "model/empty.tar.gz" +SKLEARN_CODE_KEY = "code/echo-2.4.10.tar.gz" +LINEAR_LEARNER_MODEL = s3_uri(E2E_TEST_BUCKET, "input/linearlearner/a9a/model.tar.gz") + + +def _linear_learner_payload(): + """JSON payload matching linear-learner's expected 123-feature input dim.""" + return json.dumps( + {"instances": [{"data": {"features": {"values": list(range(123))}}}]} + ) + + +@pytest.fixture(scope="module") +def sklearn_container(image_uri): + return ( + image_uri, + data_uri(SKLEARN_MODEL_KEY), + { + "SAGEMAKER_PROGRAM": "echo.py", + "SAGEMAKER_SUBMIT_DIRECTORY": data_uri(SKLEARN_CODE_KEY), + }, + ) + + +@pytest.fixture(scope="module") +def algo_container(): + return (LINEAR_LEARNER_IMAGE, LINEAR_LEARNER_MODEL, {}) + + +class TestScoringPipelines: + def test_sklearn_then_algo(self, role, sklearn_container, algo_container): + endpoint_name = None + try: + predictor, endpoint_name = deploy_inference_pipeline( + models=[sklearn_container, algo_container], + role=role, + test_name="pipe-skl-algo", + ) + predictor.content_type = "application/json" + predictor.accept = "application/json" + response = predictor.predict(_linear_learner_payload()) + assert response is not None + finally: + if endpoint_name: + delete_endpoint(endpoint_name) + + def test_algo_then_sklearn(self, role, sklearn_container, algo_container): + endpoint_name = None + try: + predictor, endpoint_name = deploy_inference_pipeline( + models=[algo_container, sklearn_container], + role=role, + test_name="pipe-algo-skl", + ) + predictor.content_type = "application/json" + predictor.accept = "application/json" + response = predictor.predict(_linear_learner_payload()) + assert response is not None + finally: + if endpoint_name: + delete_endpoint(endpoint_name) diff --git a/test/sklearn/e2e/test_script_mode_e2e.py b/test/sklearn/e2e/test_script_mode_e2e.py new file mode 100644 index 000000000000..7d152a0d1782 --- /dev/null +++ b/test/sklearn/e2e/test_script_mode_e2e.py @@ -0,0 +1,26 @@ +"""Script mode training with `requirements.txt` — verifies the container +installs extra Python dependencies from a user-provided requirements.txt +alongside the training script. +""" + +from .conftest import data_uri, run_training_job + + +CODE_TARBALL_KEY = "code/requirements.tar.gz" + + +class TestScriptModeE2E: + def test_requirements_install(self, image_uri, role): + hp = { + "sagemaker_program": "train.py", + "sagemaker_submit_directory": data_uri(CODE_TARBALL_KEY), + } + _, _, desc = run_training_job( + image_uri=image_uri, + role=role, + hyperparameters=hp, + train_s3_key="data/train.parquet", + test_name="script-reqs", + volume_size=20, + ) + assert desc["TrainingJobStatus"] == "Completed" diff --git a/test/sklearn/requirements.txt b/test/sklearn/requirements.txt new file mode 100644 index 000000000000..d371ab0d94a9 --- /dev/null +++ b/test/sklearn/requirements.txt @@ -0,0 +1 @@ +sagemaker>=2,<3 From 005cf9ce9efc4af836ebeeb4d0f235dc05bec902 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Tue, 7 Jul 2026 15:27:25 -0700 Subject: [PATCH 02/11] sklearn: drop dispatch-release workflow (moved to #6360) --- .../workflows/sklearn.dispatch-release.yml | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/sklearn.dispatch-release.yml diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.dispatch-release.yml deleted file mode 100644 index 6746155e66cf..000000000000 --- a/.github/workflows/sklearn.dispatch-release.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: "Dispatch - Scikit-learn Release" - -on: - workflow_dispatch: - inputs: - config-file: - description: "Path to image config YAML (e.g., .github/config/image/sklearn/sagemaker-py312.yml)" - required: true - type: string - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - pipeline: - uses: ./.github/workflows/sklearn.pipeline.yml - with: - config-file: ${{ inputs.config-file }} - tag-suffix: ${{ github.run_id }} - release: true - secrets: inherit From 985a82b1dc417a10affbeb88df155f5a67ac6c3c Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Tue, 7 Jul 2026 15:43:04 -0700 Subject: [PATCH 03/11] sklearn: apply pre-commit formatter (blank lines + collapse json.dumps) --- test/sklearn/e2e/conftest.py | 9 ++------- test/sklearn/e2e/test_inference.py | 5 +---- test/sklearn/e2e/test_inference_mme.py | 1 - test/sklearn/e2e/test_network_isolation.py | 1 - test/sklearn/e2e/test_scoring_pipelines.py | 5 +---- test/sklearn/e2e/test_script_mode_e2e.py | 1 - 6 files changed, 4 insertions(+), 18 deletions(-) diff --git a/test/sklearn/e2e/conftest.py b/test/sklearn/e2e/conftest.py index f8d61730282a..1a88ce6af2e5 100644 --- a/test/sklearn/e2e/conftest.py +++ b/test/sklearn/e2e/conftest.py @@ -198,9 +198,7 @@ def deploy_multi_model_endpoint( return predictor, endpoint_name, mm_model -def deploy_inference_pipeline( - models, role, test_name="pipe", instance_type="ml.m5.xlarge" -): +def deploy_inference_pipeline(models, role, test_name="pipe", instance_type="ml.m5.xlarge"): """Deploy a multi-container inference pipeline endpoint. `models` is an ordered list of (image_uri, model_data, env) tuples. @@ -210,8 +208,7 @@ def deploy_inference_pipeline( endpoint_name = random_suffix_name(f"skl-{test_name}", 32) pipeline_models = [ - Model(image_uri=img, model_data=data, role=role, env=env) - for img, data, env in models + Model(image_uri=img, model_data=data, role=role, env=env) for img, data, env in models ] pipeline = PipelineModel( name=random_suffix_name(f"skl-{test_name}-model", 32), @@ -263,5 +260,3 @@ def delete_endpoint(endpoint_name): pass if endpoint_name in _created_endpoints: _created_endpoints.remove(endpoint_name) - - diff --git a/test/sklearn/e2e/test_inference.py b/test/sklearn/e2e/test_inference.py index f7d3525f81f2..e286b89135d5 100644 --- a/test/sklearn/e2e/test_inference.py +++ b/test/sklearn/e2e/test_inference.py @@ -8,15 +8,12 @@ from .conftest import data_uri, delete_endpoint, deploy_endpoint - ECHO_MODEL_KEY = "model/empty.tar.gz" ECHO_CODE_KEY = "code/echo-2.4.10.tar.gz" def _payload(feature_dim=100): - return json.dumps( - {"instances": [{"data": {"features": {"values": list(range(feature_dim))}}}]} - ) + return json.dumps({"instances": [{"data": {"features": {"values": list(range(feature_dim))}}}]}) @pytest.fixture(scope="module") diff --git a/test/sklearn/e2e/test_inference_mme.py b/test/sklearn/e2e/test_inference_mme.py index e65b6e13a2ea..fd3a892e3c52 100644 --- a/test/sklearn/e2e/test_inference_mme.py +++ b/test/sklearn/e2e/test_inference_mme.py @@ -12,7 +12,6 @@ deploy_multi_model_endpoint, ) - # MME lazy-loads only the tarball named in `target_model`, so the sibling # `code/user_code.tar.gz` under this prefix is never invoked as a model. MME_MODEL_PREFIX = "mme_models/" diff --git a/test/sklearn/e2e/test_network_isolation.py b/test/sklearn/e2e/test_network_isolation.py index 5be7fa0d3ce3..9cce1e22b681 100644 --- a/test/sklearn/e2e/test_network_isolation.py +++ b/test/sklearn/e2e/test_network_isolation.py @@ -5,7 +5,6 @@ from .conftest import data_uri, run_training_job - CODE_TARBALL_KEY = "code/pandas-parquet-file-1.4-2.tar.gz" CODE_TARBALL_LOCAL = "/opt/ml/input/data/code/pandas-parquet-file-1.4-2.tar.gz" diff --git a/test/sklearn/e2e/test_scoring_pipelines.py b/test/sklearn/e2e/test_scoring_pipelines.py index 48914da674cd..b38ef369720a 100644 --- a/test/sklearn/e2e/test_scoring_pipelines.py +++ b/test/sklearn/e2e/test_scoring_pipelines.py @@ -14,7 +14,6 @@ s3_uri, ) - # us-west-2 SageMaker 1P algo ECR account for linear-learner LINEAR_LEARNER_IMAGE = "174872318107.dkr.ecr.us-west-2.amazonaws.com/linear-learner:latest" @@ -25,9 +24,7 @@ def _linear_learner_payload(): """JSON payload matching linear-learner's expected 123-feature input dim.""" - return json.dumps( - {"instances": [{"data": {"features": {"values": list(range(123))}}}]} - ) + return json.dumps({"instances": [{"data": {"features": {"values": list(range(123))}}}]}) @pytest.fixture(scope="module") diff --git a/test/sklearn/e2e/test_script_mode_e2e.py b/test/sklearn/e2e/test_script_mode_e2e.py index 7d152a0d1782..f956691b5836 100644 --- a/test/sklearn/e2e/test_script_mode_e2e.py +++ b/test/sklearn/e2e/test_script_mode_e2e.py @@ -5,7 +5,6 @@ from .conftest import data_uri, run_training_job - CODE_TARBALL_KEY = "code/requirements.tar.gz" From 75ed6a36f7d4121a63de80a6594bbc3b32f0530c Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Tue, 7 Jul 2026 20:01:51 -0700 Subject: [PATCH 04/11] sklearn: restore dispatch-release workflow so it's visible on this branch --- .../workflows/sklearn.dispatch-release.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/sklearn.dispatch-release.yml diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.dispatch-release.yml new file mode 100644 index 000000000000..6746155e66cf --- /dev/null +++ b/.github/workflows/sklearn.dispatch-release.yml @@ -0,0 +1,25 @@ +name: "Dispatch - Scikit-learn Release" + +on: + workflow_dispatch: + inputs: + config-file: + description: "Path to image config YAML (e.g., .github/config/image/sklearn/sagemaker-py312.yml)" + required: true + type: string + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + pipeline: + uses: ./.github/workflows/sklearn.pipeline.yml + with: + config-file: ${{ inputs.config-file }} + tag-suffix: ${{ github.run_id }} + release: true + secrets: inherit From 1b6467c6cdae38967379f268c6154a3a0d04d215 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 11:51:39 -0700 Subject: [PATCH 05/11] =?UTF-8?q?sklearn:=20rename=20e2e=20=E2=86=92=20sag?= =?UTF-8?q?emaker,=20add=20version-drift=20check,=20gate=20on=20paths-filt?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ....yml => sklearn.autorelease-sagemaker.yml} | 7 +- .github/workflows/sklearn.pipeline.yml | 20 ++-- .github/workflows/sklearn.pr-1.4-py312.yml | 7 ++ .../workflows/sklearn.tests-integ-local.yml | 19 ++++ ...-integ.yml => sklearn.tests-sagemaker.yml} | 10 +- test/sklearn/{e2e => sagemaker}/__init__.py | 0 test/sklearn/{e2e => sagemaker}/conftest.py | 2 +- .../{e2e => sagemaker}/test_inference.py | 0 .../{e2e => sagemaker}/test_inference_mme.py | 0 .../test_network_isolation.py | 0 .../test_scoring_pipelines.py | 0 .../test_script_mode.py} | 2 +- test/sklearn/scripts/check_versions.py | 94 +++++++++++++++++++ 13 files changed, 143 insertions(+), 18 deletions(-) rename .github/workflows/{sklearn.dispatch-release.yml => sklearn.autorelease-sagemaker.yml} (63%) rename .github/workflows/{sklearn.tests-integ.yml => sklearn.tests-sagemaker.yml} (92%) rename test/sklearn/{e2e => sagemaker}/__init__.py (100%) rename test/sklearn/{e2e => sagemaker}/conftest.py (99%) rename test/sklearn/{e2e => sagemaker}/test_inference.py (100%) rename test/sklearn/{e2e => sagemaker}/test_inference_mme.py (100%) rename test/sklearn/{e2e => sagemaker}/test_network_isolation.py (100%) rename test/sklearn/{e2e => sagemaker}/test_scoring_pipelines.py (100%) rename test/sklearn/{e2e/test_script_mode_e2e.py => sagemaker/test_script_mode.py} (96%) create mode 100644 test/sklearn/scripts/check_versions.py diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.autorelease-sagemaker.yml similarity index 63% rename from .github/workflows/sklearn.dispatch-release.yml rename to .github/workflows/sklearn.autorelease-sagemaker.yml index 6746155e66cf..353e68a166c5 100644 --- a/.github/workflows/sklearn.dispatch-release.yml +++ b/.github/workflows/sklearn.autorelease-sagemaker.yml @@ -1,15 +1,16 @@ -name: "Dispatch - Scikit-learn Release" +name: "Auto Release - Scikit-learn SageMaker" on: workflow_dispatch: inputs: config-file: - description: "Path to image config YAML (e.g., .github/config/image/sklearn/sagemaker-py312.yml)" + description: "Path to image config YAML" required: true type: string + default: ".github/config/image/sklearn/sagemaker-py312.yml" concurrency: - group: ${{ github.workflow }} + group: ${{ github.workflow }}-${{ inputs.config-file }} cancel-in-progress: false permissions: diff --git a/.github/workflows/sklearn.pipeline.yml b/.github/workflows/sklearn.pipeline.yml index 5fad709e95fd..27654fd12084 100644 --- a/.github/workflows/sklearn.pipeline.yml +++ b/.github/workflows/sklearn.pipeline.yml @@ -41,6 +41,11 @@ on: required: false type: boolean default: true + run-sagemaker-test: + description: "Run SageMaker tests (real training jobs + endpoints)" + required: false + type: boolean + default: true release: description: "Run release job after tests pass" required: false @@ -131,15 +136,14 @@ jobs: config-file: ${{ inputs.config-file }} image-uri: ${{ needs.build.outputs.image-uri || '' }} - # Full integration suite — SageMaker training/endpoint E2E. - # Heavy; only runs on the release pipeline, not on PRs. - integ-test-full: - if: ${{ always() && !failure() && !cancelled() && inputs.release }} + # SageMaker tests — real training jobs + endpoints + sagemaker-test: + if: ${{ always() && !failure() && !cancelled() && inputs.run-sagemaker-test }} needs: [build] concurrency: - group: ${{ github.workflow }}-integ-test-full-${{ inputs.config-file }}-${{ github.ref }} + group: ${{ github.workflow }}-sagemaker-test-${{ inputs.config-file }}-${{ github.ref }} cancel-in-progress: true - uses: ./.github/workflows/sklearn.tests-integ.yml + uses: ./.github/workflows/sklearn.tests-sagemaker.yml with: config-file: ${{ inputs.config-file }} image-uri: ${{ needs.build.outputs.image-uri || '' }} @@ -147,12 +151,12 @@ jobs: release-gate: if: >- github.ref == 'refs/heads/main' && - (contains(github.workflow_ref, '.autorelease') || contains(github.workflow_ref, '.dispatch-release')) && + contains(github.workflow_ref, '.autorelease') && !contains(github.workflow_ref, '.pr') && inputs.release && !failure() && !cancelled() && needs.build.result == 'success' - needs: [build, sanity-test, security-test, unit-test, integ-test, integ-test-full] + needs: [build, sanity-test, security-test, unit-test, integ-test, sagemaker-test] runs-on: ubuntu-latest outputs: environment: ${{ steps.check.outputs.environment }} diff --git a/.github/workflows/sklearn.pr-1.4-py312.yml b/.github/workflows/sklearn.pr-1.4-py312.yml index 599c58fe19d7..fa619187cebb 100644 --- a/.github/workflows/sklearn.pr-1.4-py312.yml +++ b/.github/workflows/sklearn.pr-1.4-py312.yml @@ -11,6 +11,7 @@ on: - ".github/workflows/sklearn.pr-1.4-py312.yml" - ".github/workflows/sklearn.tests-unit.yml" - ".github/workflows/sklearn.tests-integ-local.yml" + - ".github/workflows/sklearn.tests-sagemaker.yml" - "docker/sklearn/1.4-2-py312/**" - "docker/sklearn/resources/**" - "scripts/ci/build/sklearn/**" @@ -18,6 +19,7 @@ on: - "scripts/docker/telemetry/**" - "test/sanity/**" - "test/security/data/ecr_scan_allowlist/sklearn/**" + - "test/sklearn/**" - "test/telemetry/**" - "!docs/**" @@ -41,6 +43,7 @@ jobs: outputs: build-change: ${{ steps.changes.outputs.build-change }} integ-test-change: ${{ steps.changes.outputs.integ-test-change }} + sagemaker-test-change: ${{ steps.changes.outputs.sagemaker-test-change }} sanity-test-change: ${{ steps.changes.outputs.sanity-test-change }} telemetry-test-change: ${{ steps.changes.outputs.telemetry-test-change }} unit-test-change: ${{ steps.changes.outputs.unit-test-change }} @@ -60,6 +63,9 @@ jobs: - "test/security/data/ecr_scan_allowlist/sklearn/**" integ-test-change: - "test/sklearn/**" + sagemaker-test-change: + - "test/sklearn/sagemaker/**" + - ".github/workflows/sklearn.tests-sagemaker.yml" sanity-test-change: - "test/sanity/**" telemetry-test-change: @@ -91,6 +97,7 @@ jobs: tag-suffix: pr-${{ github.event.pull_request.number }} build: ${{ needs.check-changes.outputs.build-change == 'true' }} run-integ-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.integ-test-change == 'true' }} + run-sagemaker-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sagemaker-test-change == 'true' }} run-sanity-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sanity-test-change == 'true' }} run-security-test: ${{ needs.check-changes.outputs.build-change == 'true' }} run-telemetry-test: false diff --git a/.github/workflows/sklearn.tests-integ-local.yml b/.github/workflows/sklearn.tests-integ-local.yml index b783ee6ff1df..431a1395710c 100644 --- a/.github/workflows/sklearn.tests-integ-local.yml +++ b/.github/workflows/sklearn.tests-integ-local.yml @@ -61,6 +61,25 @@ jobs: echo "framework-version=$(yq '.metadata.framework_version' "$CONFIG_FILE")" >> $GITHUB_OUTPUT echo "sklearn-branch=$(yq '.build.sklearn_container_branch // "master"' "$CONFIG_FILE")" >> $GITHUB_OUTPUT echo "python-version=$(yq '.build.python_version' "$CONFIG_FILE")" >> $GITHUB_OUTPUT + echo "dockerfile=$(yq '.build.dockerfile' "$CONFIG_FILE")" >> $GITHUB_OUTPUT + + - name: Verify pinned package versions match declared requirements.txt + if: steps.check.outputs.exists == 'true' + env: + IMAGE_URI: ${{ steps.image.outputs.image-uri }} + DOCKERFILE: ${{ steps.config.outputs.dockerfile }} + run: | + REQS_FILE="$(dirname "$DOCKERFILE")/requirements.txt" + if [[ ! -f "$REQS_FILE" ]]; then + echo "No requirements.txt at $REQS_FILE — skipping version check" + exit 0 + fi + docker run --rm \ + -v "$PWD/$REQS_FILE:/tmp/requirements.txt:ro" \ + -v "$PWD/test/sklearn/scripts/check_versions.py:/tmp/check_versions.py:ro" \ + --entrypoint python3 \ + "$IMAGE_URI" \ + /tmp/check_versions.py /tmp/requirements.txt - name: Clone sagemaker-scikit-learn-container (tests + source) if: steps.check.outputs.exists == 'true' diff --git a/.github/workflows/sklearn.tests-integ.yml b/.github/workflows/sklearn.tests-sagemaker.yml similarity index 92% rename from .github/workflows/sklearn.tests-integ.yml rename to .github/workflows/sklearn.tests-sagemaker.yml index 0df82345b08d..08d2e9d46d31 100644 --- a/.github/workflows/sklearn.tests-integ.yml +++ b/.github/workflows/sklearn.tests-sagemaker.yml @@ -1,4 +1,4 @@ -name: Reusable Scikit-learn Integration Tests +name: Reusable Scikit-learn SageMaker Tests on: workflow_call: @@ -52,8 +52,8 @@ jobs: CONFIG_FILE="${{ inputs.config-file }}" echo "framework-version=$(yq '.metadata.framework_version' "$CONFIG_FILE")" >> $GITHUB_OUTPUT - # SageMaker E2E tests — real training jobs + endpoints - e2e-test: + # SageMaker tests — real training jobs + endpoints + sagemaker-test: if: ${{ needs.preflight.outputs.exists == 'true' }} needs: [preflight] timeout-minutes: 120 @@ -65,7 +65,7 @@ jobs: - test_inference_mme - test_network_isolation - test_scoring_pipelines - - test_script_mode_e2e + - test_script_mode runs-on: - codebuild-runner-${{ github.run_id }}-${{ github.run_attempt }} fleet:default-runner @@ -87,4 +87,4 @@ jobs: python3 -m pytest -v --tb=short -rA --log-cli-level=INFO \ --image-uri ${{ needs.preflight.outputs.image-uri }} \ --sklearn-version ${{ needs.preflight.outputs.framework-version }} \ - sklearn/e2e/${{ matrix.test-module }}.py + sklearn/sagemaker/${{ matrix.test-module }}.py diff --git a/test/sklearn/e2e/__init__.py b/test/sklearn/sagemaker/__init__.py similarity index 100% rename from test/sklearn/e2e/__init__.py rename to test/sklearn/sagemaker/__init__.py diff --git a/test/sklearn/e2e/conftest.py b/test/sklearn/sagemaker/conftest.py similarity index 99% rename from test/sklearn/e2e/conftest.py rename to test/sklearn/sagemaker/conftest.py index 1a88ce6af2e5..7c8e50f328db 100644 --- a/test/sklearn/e2e/conftest.py +++ b/test/sklearn/sagemaker/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures and helpers for Scikit-learn SageMaker E2E tests.""" +"""Shared fixtures and helpers for Scikit-learn SageMaker tests.""" import logging import time diff --git a/test/sklearn/e2e/test_inference.py b/test/sklearn/sagemaker/test_inference.py similarity index 100% rename from test/sklearn/e2e/test_inference.py rename to test/sklearn/sagemaker/test_inference.py diff --git a/test/sklearn/e2e/test_inference_mme.py b/test/sklearn/sagemaker/test_inference_mme.py similarity index 100% rename from test/sklearn/e2e/test_inference_mme.py rename to test/sklearn/sagemaker/test_inference_mme.py diff --git a/test/sklearn/e2e/test_network_isolation.py b/test/sklearn/sagemaker/test_network_isolation.py similarity index 100% rename from test/sklearn/e2e/test_network_isolation.py rename to test/sklearn/sagemaker/test_network_isolation.py diff --git a/test/sklearn/e2e/test_scoring_pipelines.py b/test/sklearn/sagemaker/test_scoring_pipelines.py similarity index 100% rename from test/sklearn/e2e/test_scoring_pipelines.py rename to test/sklearn/sagemaker/test_scoring_pipelines.py diff --git a/test/sklearn/e2e/test_script_mode_e2e.py b/test/sklearn/sagemaker/test_script_mode.py similarity index 96% rename from test/sklearn/e2e/test_script_mode_e2e.py rename to test/sklearn/sagemaker/test_script_mode.py index f956691b5836..a75692c9f694 100644 --- a/test/sklearn/e2e/test_script_mode_e2e.py +++ b/test/sklearn/sagemaker/test_script_mode.py @@ -8,7 +8,7 @@ CODE_TARBALL_KEY = "code/requirements.tar.gz" -class TestScriptModeE2E: +class TestScriptMode: def test_requirements_install(self, image_uri, role): hp = { "sagemaker_program": "train.py", diff --git a/test/sklearn/scripts/check_versions.py b/test/sklearn/scripts/check_versions.py new file mode 100644 index 000000000000..60de197c5efc --- /dev/null +++ b/test/sklearn/scripts/check_versions.py @@ -0,0 +1,94 @@ +"""Verify installed package versions match declared specifiers in requirements.txt. + +Runs inside the built sklearn container. Reads a requirements.txt (copied in by +the caller), parses each entry as a PEP 508 requirement, and asserts the +installed version satisfies the declared specifier (==, >=, <, ~=, etc.). + +Bare entries with no specifier (e.g. `certifi`) can't be verified — logged and +skipped. Unparseable lines are logged and skipped. + +Usage: python3 check_versions.py + +Exits non-zero on any drift, with a summary of what changed. +""" + +import importlib.metadata +import sys + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import Version + + +def parse_reqs(path): + reqs = [] + unparseable = [] + with open(path) as f: + for raw in f: + line = raw.split("#", 1)[0].strip() + if not line: + continue + try: + reqs.append((Requirement(line), line)) + except InvalidRequirement: + unparseable.append(line) + return reqs, unparseable + + +def installed_version(name): + for candidate in (name, name.replace("-", "_"), name.replace("_", "-")): + try: + return importlib.metadata.version(candidate) + except importlib.metadata.PackageNotFoundError: + continue + return None + + +def main(path): + reqs, unparseable = parse_reqs(path) + if not reqs: + print(f"No requirements found in {path}", file=sys.stderr) + sys.exit(1) + + drift = [] + missing = [] + unconstrained = [] + checked = 0 + + for req, raw in reqs: + actual = installed_version(req.name) + if actual is None: + missing.append(req.name) + continue + if not req.specifier: + unconstrained.append(raw) + continue + checked += 1 + if Version(actual) not in req.specifier: + drift.append((req.name, str(req.specifier), actual)) + + print(f"Checked {checked} constrained packages against declared specifiers.") + if unconstrained: + print(f"Skipped {len(unconstrained)} unconstrained entries (no version specifier):") + for line in unconstrained: + print(f" {line}") + if unparseable: + print(f"Skipped {len(unparseable)} unparseable lines:", file=sys.stderr) + for line in unparseable: + print(f" {line}", file=sys.stderr) + + if not drift and not missing: + print(f"All {checked} constrained packages satisfy declared specifiers.") + return + + for name, spec, act in drift: + print(f"DRIFT: {name} declared{spec} but installed=={act}", file=sys.stderr) + for name in missing: + print(f"MISSING: {name} declared but not installed", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: check_versions.py ", file=sys.stderr) + sys.exit(2) + main(sys.argv[1]) From ed1499e0322daddaa5b461887c800e032fff6910 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 12:01:58 -0700 Subject: [PATCH 06/11] sklearn: merge main, delete stale dispatch-release.yml, fix typo --- .../workflows/sklearn.dispatch-release.yml | 25 ------------------- test/sklearn/scripts/check_versions.py | 16 ++++++------ 2 files changed, 8 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/sklearn.dispatch-release.yml diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.dispatch-release.yml deleted file mode 100644 index 6746155e66cf..000000000000 --- a/.github/workflows/sklearn.dispatch-release.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: "Dispatch - Scikit-learn Release" - -on: - workflow_dispatch: - inputs: - config-file: - description: "Path to image config YAML (e.g., .github/config/image/sklearn/sagemaker-py312.yml)" - required: true - type: string - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - pipeline: - uses: ./.github/workflows/sklearn.pipeline.yml - with: - config-file: ${{ inputs.config-file }} - tag-suffix: ${{ github.run_id }} - release: true - secrets: inherit diff --git a/test/sklearn/scripts/check_versions.py b/test/sklearn/scripts/check_versions.py index 60de197c5efc..4541be4bc475 100644 --- a/test/sklearn/scripts/check_versions.py +++ b/test/sklearn/scripts/check_versions.py @@ -5,7 +5,7 @@ installed version satisfies the declared specifier (==, >=, <, ~=, etc.). Bare entries with no specifier (e.g. `certifi`) can't be verified — logged and -skipped. Unparseable lines are logged and skipped. +skipped. Unparsable lines are logged and skipped. Usage: python3 check_versions.py @@ -21,7 +21,7 @@ def parse_reqs(path): reqs = [] - unparseable = [] + unparsable = [] with open(path) as f: for raw in f: line = raw.split("#", 1)[0].strip() @@ -30,8 +30,8 @@ def parse_reqs(path): try: reqs.append((Requirement(line), line)) except InvalidRequirement: - unparseable.append(line) - return reqs, unparseable + unparsable.append(line) + return reqs, unparsable def installed_version(name): @@ -44,7 +44,7 @@ def installed_version(name): def main(path): - reqs, unparseable = parse_reqs(path) + reqs, unparsable = parse_reqs(path) if not reqs: print(f"No requirements found in {path}", file=sys.stderr) sys.exit(1) @@ -71,9 +71,9 @@ def main(path): print(f"Skipped {len(unconstrained)} unconstrained entries (no version specifier):") for line in unconstrained: print(f" {line}") - if unparseable: - print(f"Skipped {len(unparseable)} unparseable lines:", file=sys.stderr) - for line in unparseable: + if unparsable: + print(f"Skipped {len(unparsable)} unparsable lines:", file=sys.stderr) + for line in unparsable: print(f" {line}", file=sys.stderr) if not drift and not missing: From e9588c2992fa2113cc97e4460b18966c870f5620 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 12:56:11 -0700 Subject: [PATCH 07/11] sklearn: rebuild image when sagemaker tests change so PR tests fresh code --- .github/workflows/sklearn.pr-1.4-py312.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sklearn.pr-1.4-py312.yml b/.github/workflows/sklearn.pr-1.4-py312.yml index fa619187cebb..5f125265d73b 100644 --- a/.github/workflows/sklearn.pr-1.4-py312.yml +++ b/.github/workflows/sklearn.pr-1.4-py312.yml @@ -95,7 +95,7 @@ jobs: with: config-file: ${{ matrix.config_file }} tag-suffix: pr-${{ github.event.pull_request.number }} - build: ${{ needs.check-changes.outputs.build-change == 'true' }} + build: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sagemaker-test-change == 'true' }} run-integ-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.integ-test-change == 'true' }} run-sagemaker-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sagemaker-test-change == 'true' }} run-sanity-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sanity-test-change == 'true' }} From 304ca9f863d4f6e50645953c6585b4047ed8bce0 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 13:19:15 -0700 Subject: [PATCH 08/11] sklearn: revert build-on-test-change, route prod fallback to sagemaker ecr --- .github/workflows/sklearn.pr-1.4-py312.yml | 2 +- .github/workflows/sklearn.tests-integ-local.yml | 2 +- .github/workflows/sklearn.tests-sagemaker.yml | 2 +- .github/workflows/sklearn.tests-unit.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sklearn.pr-1.4-py312.yml b/.github/workflows/sklearn.pr-1.4-py312.yml index 5f125265d73b..fa619187cebb 100644 --- a/.github/workflows/sklearn.pr-1.4-py312.yml +++ b/.github/workflows/sklearn.pr-1.4-py312.yml @@ -95,7 +95,7 @@ jobs: with: config-file: ${{ matrix.config_file }} tag-suffix: pr-${{ github.event.pull_request.number }} - build: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sagemaker-test-change == 'true' }} + build: ${{ needs.check-changes.outputs.build-change == 'true' }} run-integ-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.integ-test-change == 'true' }} run-sagemaker-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sagemaker-test-change == 'true' }} run-sanity-test: ${{ needs.check-changes.outputs.build-change == 'true' || needs.check-changes.outputs.sanity-test-change == 'true' }} diff --git a/.github/workflows/sklearn.tests-integ-local.yml b/.github/workflows/sklearn.tests-integ-local.yml index 431a1395710c..0947a75a86f5 100644 --- a/.github/workflows/sklearn.tests-integ-local.yml +++ b/.github/workflows/sklearn.tests-integ-local.yml @@ -35,7 +35,7 @@ jobs: with: image-uri: ${{ inputs.image-uri }} ci-aws-account-id: ${{ vars.CI_AWS_ACCOUNT_ID }} - prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID }} + prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID_SAGEMAKER }} aws-region: ${{ vars.AWS_REGION }} config-file: ${{ inputs.config-file }} diff --git a/.github/workflows/sklearn.tests-sagemaker.yml b/.github/workflows/sklearn.tests-sagemaker.yml index 08d2e9d46d31..d1679f825eca 100644 --- a/.github/workflows/sklearn.tests-sagemaker.yml +++ b/.github/workflows/sklearn.tests-sagemaker.yml @@ -36,7 +36,7 @@ jobs: with: image-uri: ${{ inputs.image-uri }} ci-aws-account-id: ${{ vars.CI_AWS_ACCOUNT_ID }} - prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID }} + prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID_SAGEMAKER }} aws-region: ${{ vars.AWS_REGION }} config-file: ${{ inputs.config-file }} diff --git a/.github/workflows/sklearn.tests-unit.yml b/.github/workflows/sklearn.tests-unit.yml index 4dab855018c5..f06fea714f5d 100644 --- a/.github/workflows/sklearn.tests-unit.yml +++ b/.github/workflows/sklearn.tests-unit.yml @@ -32,7 +32,7 @@ jobs: with: image-uri: ${{ inputs.image-uri }} ci-aws-account-id: ${{ vars.CI_AWS_ACCOUNT_ID }} - prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID }} + prod-aws-account-id: ${{ vars.PROD_AWS_ACCOUNT_ID_SAGEMAKER }} aws-region: ${{ vars.AWS_REGION }} config-file: ${{ inputs.config-file }} From 305ab01b7ce0a2caafcc82d71c8605d7af55508c Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 14:14:42 -0700 Subject: [PATCH 09/11] sklearn: log endpoint deploy, predict response, and session cleanup --- test/sklearn/sagemaker/conftest.py | 62 ++++++++++++++----- test/sklearn/sagemaker/test_inference.py | 4 +- test/sklearn/sagemaker/test_inference_mme.py | 5 +- .../sagemaker/test_scoring_pipelines.py | 5 +- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/test/sklearn/sagemaker/conftest.py b/test/sklearn/sagemaker/conftest.py index 7c8e50f328db..11ee7a84eae2 100644 --- a/test/sklearn/sagemaker/conftest.py +++ b/test/sklearn/sagemaker/conftest.py @@ -33,20 +33,26 @@ def data_uri(key): def cleanup_resources(): """Delete all SageMaker resources created during the test session.""" sm = boto3.client("sagemaker") + LOGGER.info( + f"Session cleanup: {len(_created_endpoints)} endpoints, {len(_created_models)} models" + ) for ep in _created_endpoints: try: sm.delete_endpoint(EndpointName=ep) - except Exception: - pass + LOGGER.info(f"Deleted endpoint {ep}") + except Exception as e: + LOGGER.warning(f"Failed to delete endpoint {ep}: {e}") try: sm.delete_endpoint_config(EndpointConfigName=ep) - except Exception: - pass + LOGGER.info(f"Deleted endpoint-config {ep}") + except Exception as e: + LOGGER.warning(f"Failed to delete endpoint-config {ep}: {e}") for model_name in _created_models: try: sm.delete_model(ModelName=model_name) - except Exception: - pass + LOGGER.info(f"Deleted model {model_name}") + except Exception as e: + LOGGER.warning(f"Failed to delete model {model_name}: {e}") _created_endpoints.clear() _created_models.clear() @@ -142,6 +148,8 @@ def deploy_endpoint( role=role, env=env, ) + LOGGER.info(f"Deploying endpoint {endpoint_name} on {instance_type} (model_data={model_data})") + start = time.time() try: model.deploy( initial_instance_count=1, @@ -152,6 +160,7 @@ def deploy_endpoint( if model.name: _created_models.append(model.name) raise + LOGGER.info(f"Endpoint {endpoint_name} InService in {time.time() - start:.0f}s") _created_models.append(model.name) _created_endpoints.append(endpoint_name) predictor = Predictor(endpoint_name=endpoint_name) @@ -182,6 +191,8 @@ def deploy_multi_model_endpoint( role=role, env=env, ) + LOGGER.info(f"Deploying MME {endpoint_name} on {instance_type} (prefix={model_data_prefix})") + start = time.time() try: mm_model.deploy( initial_instance_count=1, @@ -192,6 +203,7 @@ def deploy_multi_model_endpoint( if mm_model.name: _created_models.append(mm_model.name) raise + LOGGER.info(f"MME {endpoint_name} InService in {time.time() - start:.0f}s") _created_models.append(mm_model.name) _created_endpoints.append(endpoint_name) predictor = Predictor(endpoint_name=endpoint_name) @@ -215,6 +227,11 @@ def deploy_inference_pipeline(models, role, test_name="pipe", instance_type="ml. role=role, models=pipeline_models, ) + LOGGER.info( + f"Deploying pipeline endpoint {endpoint_name} on {instance_type} " + f"({len(pipeline_models)} containers)" + ) + start = time.time() try: pipeline.deploy( initial_instance_count=1, @@ -226,6 +243,7 @@ def deploy_inference_pipeline(models, role, test_name="pipe", instance_type="ml. if m.name: _created_models.append(m.name) raise + LOGGER.info(f"Pipeline endpoint {endpoint_name} InService in {time.time() - start:.0f}s") for m in pipeline_models: if m.name: _created_models.append(m.name) @@ -234,6 +252,19 @@ def deploy_inference_pipeline(models, role, test_name="pipe", instance_type="ml. return predictor, endpoint_name +def predict_and_log(predictor, payload, **kwargs): + """Invoke an endpoint and log latency + first 200 chars of the response.""" + payload_len = len(payload) if isinstance(payload, (str, bytes)) else len(str(payload)) + LOGGER.info(f"POST {predictor.endpoint_name} payload_len={payload_len}") + start = time.time() + response = predictor.predict(payload, **kwargs) + LOGGER.info( + f"{predictor.endpoint_name} responded in {(time.time() - start) * 1000:.0f}ms: " + f"{str(response)[:200]}" + ) + return response + + def delete_endpoint(endpoint_name): """Delete endpoint, endpoint config, and associated model(s).""" sm = boto3.client("sagemaker") @@ -244,19 +275,22 @@ def delete_endpoint(endpoint_name): if model_name: try: sm.delete_model(ModelName=model_name) - except Exception: - pass + LOGGER.info(f"Deleted model {model_name}") + except Exception as e: + LOGGER.warning(f"Failed to delete model {model_name}: {e}") if model_name in _created_models: _created_models.remove(model_name) - except Exception: - pass + except Exception as e: + LOGGER.warning(f"Failed to describe endpoint-config {endpoint_name}: {e}") try: sm.delete_endpoint(EndpointName=endpoint_name) - except Exception: - pass + LOGGER.info(f"Deleted endpoint {endpoint_name}") + except Exception as e: + LOGGER.warning(f"Failed to delete endpoint {endpoint_name}: {e}") try: sm.delete_endpoint_config(EndpointConfigName=endpoint_name) - except Exception: - pass + LOGGER.info(f"Deleted endpoint-config {endpoint_name}") + except Exception as e: + LOGGER.warning(f"Failed to delete endpoint-config {endpoint_name}: {e}") if endpoint_name in _created_endpoints: _created_endpoints.remove(endpoint_name) diff --git a/test/sklearn/sagemaker/test_inference.py b/test/sklearn/sagemaker/test_inference.py index e286b89135d5..798c3391e28d 100644 --- a/test/sklearn/sagemaker/test_inference.py +++ b/test/sklearn/sagemaker/test_inference.py @@ -6,7 +6,7 @@ import pytest -from .conftest import data_uri, delete_endpoint, deploy_endpoint +from .conftest import data_uri, delete_endpoint, deploy_endpoint, predict_and_log ECHO_MODEL_KEY = "model/empty.tar.gz" ECHO_CODE_KEY = "code/echo-2.4.10.tar.gz" @@ -37,7 +37,7 @@ def test_json_inference(self, image_uri, role, echo_env): ) predictor.content_type = "application/json" predictor.accept = "application/json" - response = predictor.predict(_payload()) + response = predict_and_log(predictor, _payload()) assert response is not None finally: if endpoint_name: diff --git a/test/sklearn/sagemaker/test_inference_mme.py b/test/sklearn/sagemaker/test_inference_mme.py index fd3a892e3c52..b3fc87a70bed 100644 --- a/test/sklearn/sagemaker/test_inference_mme.py +++ b/test/sklearn/sagemaker/test_inference_mme.py @@ -10,6 +10,7 @@ delete_endpoint, deploy_endpoint, deploy_multi_model_endpoint, + predict_and_log, ) # MME lazy-loads only the tarball named in `target_model`, so the sibling @@ -48,7 +49,7 @@ def test_csv_single_model(self, image_uri, role, mme_env): ) predictor.content_type = "text/csv" predictor.accept = "text/csv" - response = predictor.predict(SAMPLE_PAYLOAD) + response = predict_and_log(predictor, SAMPLE_PAYLOAD) assert response is not None finally: if endpoint_name: @@ -67,7 +68,7 @@ def test_csv_multimodel(self, image_uri, role, mme_env): predictor.content_type = "text/csv" predictor.accept = "text/csv" for target_model in MME_TARGET_MODELS: - response = predictor.predict(SAMPLE_PAYLOAD, target_model=target_model) + response = predict_and_log(predictor, SAMPLE_PAYLOAD, target_model=target_model) assert response is not None finally: if endpoint_name: diff --git a/test/sklearn/sagemaker/test_scoring_pipelines.py b/test/sklearn/sagemaker/test_scoring_pipelines.py index b38ef369720a..72f752a8be33 100644 --- a/test/sklearn/sagemaker/test_scoring_pipelines.py +++ b/test/sklearn/sagemaker/test_scoring_pipelines.py @@ -11,6 +11,7 @@ data_uri, delete_endpoint, deploy_inference_pipeline, + predict_and_log, s3_uri, ) @@ -55,7 +56,7 @@ def test_sklearn_then_algo(self, role, sklearn_container, algo_container): ) predictor.content_type = "application/json" predictor.accept = "application/json" - response = predictor.predict(_linear_learner_payload()) + response = predict_and_log(predictor, _linear_learner_payload()) assert response is not None finally: if endpoint_name: @@ -71,7 +72,7 @@ def test_algo_then_sklearn(self, role, sklearn_container, algo_container): ) predictor.content_type = "application/json" predictor.accept = "application/json" - response = predictor.predict(_linear_learner_payload()) + response = predict_and_log(predictor, _linear_learner_payload()) assert response is not None finally: if endpoint_name: From e82823b9973be884b9b716fd7e7a307a2ec8f7bf Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 16:18:35 -0700 Subject: [PATCH 10/11] =?UTF-8?q?sklearn:=20rename=20autorelease-sagemaker?= =?UTF-8?q?=20=E2=86=92=20dispatch-release=20(no=20cron)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...n.autorelease-sagemaker.yml => sklearn.dispatch-release.yml} | 0 .github/workflows/sklearn.pipeline.yml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{sklearn.autorelease-sagemaker.yml => sklearn.dispatch-release.yml} (100%) diff --git a/.github/workflows/sklearn.autorelease-sagemaker.yml b/.github/workflows/sklearn.dispatch-release.yml similarity index 100% rename from .github/workflows/sklearn.autorelease-sagemaker.yml rename to .github/workflows/sklearn.dispatch-release.yml diff --git a/.github/workflows/sklearn.pipeline.yml b/.github/workflows/sklearn.pipeline.yml index 27654fd12084..5c99c579f045 100644 --- a/.github/workflows/sklearn.pipeline.yml +++ b/.github/workflows/sklearn.pipeline.yml @@ -151,7 +151,7 @@ jobs: release-gate: if: >- github.ref == 'refs/heads/main' && - contains(github.workflow_ref, '.autorelease') && + contains(github.workflow_ref, '.dispatch-release') && !contains(github.workflow_ref, '.pr') && inputs.release && !failure() && !cancelled() && From f63d83b5e13b66c7cbd0a2181a2e4d0c90507547 Mon Sep 17 00:00:00 2001 From: Jinyan Li Date: Wed, 8 Jul 2026 16:23:34 -0700 Subject: [PATCH 11/11] sklearn: use 'Dispatch - Scikit-learn Release' as workflow name --- .github/workflows/sklearn.dispatch-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sklearn.dispatch-release.yml b/.github/workflows/sklearn.dispatch-release.yml index 353e68a166c5..d310cb28c1c2 100644 --- a/.github/workflows/sklearn.dispatch-release.yml +++ b/.github/workflows/sklearn.dispatch-release.yml @@ -1,4 +1,4 @@ -name: "Auto Release - Scikit-learn SageMaker" +name: "Dispatch - Scikit-learn Release" on: workflow_dispatch: