diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1299357..db55b91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,47 @@ on: branches: [main, develop] pull_request: branches: [main, develop] + workflow_call: + inputs: + prepare-publish: + description: Require main and an unpublished version before building + type: boolean + default: false permissions: contents: read jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Check publish ref and PyPI version + if: inputs.prepare-publish + run: python scripts/publish_check.py + - name: Install UV CLI + run: python -m pip install uv==0.12.19 + - name: Build wheel and source archive + run: uv build + - name: Validate metadata and README rendering + run: uvx twine check --strict dist/* + - name: Upload distributions for testing and publishing + uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + if-no-files-found: error + base-install: name: Base wheel smoke (${{ matrix.os }}, Python 3.14) runs-on: ${{ matrix.os }} + needs: build strategy: fail-fast: false matrix: @@ -26,8 +59,11 @@ jobs: python-version: '3.14' - name: Install UV CLI run: python -m pip install uv==0.12.19 - - name: Build wheel and source archive - run: uv build + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist - name: Install fixture producer from lockfile run: uv sync --locked --group dev --all-extras - name: Produce bundle and check artifact contents @@ -86,6 +122,7 @@ jobs: tests: name: Tests (${{ matrix.os }}, Python 3.14) runs-on: ${{ matrix.os }} + needs: build strategy: fail-fast: false matrix: @@ -101,8 +138,25 @@ jobs: run: python -m pip install uv==0.12.19 - name: Install development dependencies from lockfile run: uv sync --locked --group dev --all-extras + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist + - name: Install the wheel that will be published + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + test_python="$GITHUB_WORKSPACE/.venv/Scripts/python.exe" + else + test_python="$GITHUB_WORKSPACE/.venv/bin/python" + fi + uv pip install --python "$test_python" --no-deps --reinstall dist/*.whl + "$test_python" -I -c 'import mlbundle; from pathlib import Path; assert "site-packages" in Path(mlbundle.__file__).parts; print(mlbundle.__version__, mlbundle.__file__)' - name: Run tests with coverage - run: uv run pytest + shell: bash + working-directory: ${{ runner.temp }} + run: uv run --project "$GITHUB_WORKSPACE" --no-sync pytest "$GITHUB_WORKSPACE/tests" -c "$GITHUB_WORKSPACE/pyproject.toml" --cov-config="$GITHUB_WORKSPACE/pyproject.toml" --cov-report="xml:$GITHUB_WORKSPACE/coverage.xml" - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..054c4a0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mlbundle-pypi-publish + cancel-in-progress: false + +jobs: + build: + uses: ./.github/workflows/ci.yml + with: + prepare-publish: true + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi_env + url: https://pypi.org/p/mlbundle + permissions: + id-token: write + steps: + - name: Download CI-validated distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist + - name: Publish to PyPI with Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/AGENTS.md b/AGENTS.md index f90c248..1f030bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,8 +136,8 @@ still preserve validation, security and reproducibility. or PR text. - `C:\software\mlsuite` and `C:\software\mlschema` are read-only references. Never modify, commit or run write commands inside either checkout. -- Do not publish to PyPI, create releases or version tags, or add a publish job. - "Tags" in this project means GitHub repository topics. +- Publish only via manual `publish.yml` from `main`, through `pypi_env` with owner approval, on the owner's explicit request for that specific version. + Agents never run or approve it on their own initiative or add publish paths/tokens/secrets. No GitHub releases/version tags; "tags" means GitHub repository topics. - sklearn-to-ONNX conversion, per-model environments, bundle signing and additional libraries (including LightGBM and PyTorch) are outside the project scope. - Never claim a verification that was not run; report its real output or blocker. @@ -154,8 +154,8 @@ uv run pre-commit install ``` After changing dependency metadata, use `uv sync` to regenerate `uv.lock` and -commit the lockfile. CI uses `uv sync --locked --group dev --all-extras` on Python 3.14. -The default dev group installs `mlbundle[all]`; base users can omit the dev group. +commit the lockfile. CI and manual `publish.yml` use locked dev/all extras on 3.14. +Default dev installs `mlbundle[all]`; base users can omit it. Publishing reuses CI. Ruff handles lint and formatting; Pyright uses the family's basic mode. ## Verifying @@ -174,8 +174,8 @@ The Ruff commands also work without the final `.` because the default is the current directory: `uv run ruff check` and `uv run ruff format --check`. `uv run pytest` runs `tests/`, measures branch coverage and enforces the family's 80% minimum, writing terminal and XML reports. Tests use the installed package; -its version comes from package metadata. CI checks lint and types and runs tests -and a clean base-wheel smoke check on Ubuntu and Windows. The local build must produce a wheel and source archive. +its version comes from package metadata. CI checks lint/types and the built wheel +on Ubuntu/Windows; manual `publish.yml` requires that CI. Build a wheel and sdist. Start with the smallest proof of a behavior change, then broaden when needed. If a check cannot run, report the exact command and blocker. Never claim a test, @@ -285,9 +285,9 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/inspection.py`, `results.py` — metadata-only inspection and result records without adapter resolution. - `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. -- `scripts/package_smoke.py` — fixture, isolated install and artifact-content checks. +- `scripts/package_smoke.py`, `publish_check.py` — artifact checks and publish preflight. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. -- `.github/workflows/ci.yml` — lint, types and cross-platform tests. +- `.github/workflows/ci.yml`, `publish.yml` — shared CI and owner-approved publishing. - `.pre-commit-config.yaml` — Ruff, Pyright and basic file hygiene. - `README.md`, `SECURITY.md` and this file — project and contributor context. diff --git a/README.md b/README.md index f2dbd77..e9f98aa 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,15 @@ mlbundle replaces three gaps in separate model/DataFrame uploads: - **Training data leaving the machine:** schema inference runs locally; training rows are included only when explicitly requested as samples. -**Pre-release v0.1.0; format version 1 is not yet frozen.** Nothing is published -on PyPI. Requires **Python >=3.14**. The tested baseline is CPython 3.14 on -Windows and Linux; the schema extra currently works only on Python 3.14. +**Pre-alpha v0.1.0a1, published to reserve the PyPI name.** Format version 1 is +not frozen; bundles may need re-export. The security design has not had an +independent review: do not accept bundles from untrusted parties. +Requires **Python >=3.14**; tested on Windows/Linux. Schema requires Python 3.14. ## Install and try scikit-learn -Install from Git with -`python -m pip install "mlbundle[sklearn] @ git+https://github.com/UlloaSP/mlbundle.git@develop"`. +Install the pre-release with +`python -m pip install --pre "mlbundle[sklearn]"`. Replace `[sklearn]` with `[xgboost]`, `[catboost]`, `[onnx]`, `[schema]` or `[all]` for the corresponding dependencies. Combine extras as `[sklearn,schema]` for local DataFrame inference. Omit the brackets for a base install that can inspect @@ -99,7 +100,7 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances -are verified on load. [The CatBoost contract](docs/format-catboost.md) details +are verified on load. [The CatBoost contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-catboost.md) details wrapper recovery, native-parser limits and the private in-memory serializer. ## Composite pipelines @@ -132,7 +133,7 @@ Each needs a fitted, data-free width proof. Unknown widths, FeatureUnion/KNNImpu slice/callable/scalar selectors, caching, metadata routing and other native placements remain rejected. Step names/outer options are declarations; trees address transformed features, without original-feature attribution. Schema/dtypes/samples describe original -inputs; six members permit schema plus explicit samples. See [the pipeline contract](docs/format-pipeline.md). +inputs; six members permit schema plus explicit samples. See [the pipeline contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-pipeline.md). ## Existing ONNX files @@ -157,7 +158,7 @@ Paths and bytes are preserved unchanged; a ModelProto is serialized once. There is no conversion. `native_model` contains the original `data` and CPU `session`. Native graph labels are verified; supplied labels must agree with them. Without native/supplied labels, encoded indices are generated. ONNX has -no visualization structure. See [the exact ONNX contract](docs/format-onnx.md). +no visualization structure. See [the exact ONNX contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). ## Local schema and explicit samples @@ -199,7 +200,7 @@ steps, categories (including unused), one-hot suffixes, pair-axis metadata and class labels. No inferred ranges, means, counts or arbitrary text/example rows. Names/categories and learned models/structure can reveal training information. Explicit `sample_rows=N` includes the first N feature rows, without target/index; -N must be positive and within the frame. See [schema and samples](docs/format-schema.md). +N must be positive and within the frame. See [schema and samples](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-schema.md). ## Inspect, load and the CLI @@ -229,9 +230,9 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, univariate selectors and closed NumPy FunctionTransformer functions round-trip, - including nested ColumnTransformer pipelines; see [the exact lists](docs/format-state.md). + including nested ColumnTransformer pipelines; see [the exact lists](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). All-sklearn Pipeline structure stays null; composite trees expose the inner boundary. See - [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). + [tested configurations](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn_families.py) and [core round trips/rejections](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn.py). - **Sklearn rejections:** custom estimators/subclasses, functions/types outside the closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without probabilities, multilabel/ClassifierChain layouts, unsupported native placements, @@ -240,22 +241,22 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. - **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. Unknown state fails; export/load sklearn versions must match. Configurations - must satisfy the catalog and round trip; see [state ownership](docs/format-state.md). + must satisfy the catalog and round trip; see [state ownership](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). - **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native gbtree (including XGBoost 3.4.1 dart normalized to gbtree). Closed probability/regression objectives; raw boosters, RF wrappers, subclasses, ranking/survival/custom objectives and unsupported storage are rejected. At most 100,000 trees/depth 14, - with complete state/dumps; see [the mapping](docs/format-adapters.md#implemented-xgboost-mapping). + with complete state/dumps; see [the mapping](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-adapters.md#implemented-xgboost-mapping). - **ONNX:** fixed-width rank-two numeric inputs; one regression/probability target, optional native labels and dynamic/one-row batches. Unsupported layouts, external data/custom domains/functions and runtime/training configuration fail - before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](docs/format-onnx.md). + before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). Archive ceilings: **6 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). -Callers may only lower `ArchiveLimits`. Native [state](docs/format-state.md)/[ONNX](docs/format-onnx.md) +Callers may only lower `ArchiveLimits`. Native [state](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md)/[ONNX](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md) bounds do not cap process memory, prediction rows or CPU. ## Bundle contents and security @@ -267,7 +268,7 @@ Every bundle contains `manifest.json` and one native member, or the declared The manifest records identity/time/writer, model parameters/counts/state, ordered features/dtypes/origin, target labels/name, environment, member SHA-256/sizes, references and free-form user metadata. The normative -[format specification](docs/format.md) owns these records and verification rules. +[format specification](https://github.com/UlloaSP/mlbundle/blob/main/docs/format.md) owns these records and verification rules. Treat bundles as untrusted. The reader validates ZIP layout, allowed members, limits, strict JSON/manifest and hashes; adapters use closed trust/loading paths @@ -275,7 +276,7 @@ and recompute claims. **Hashes detect corruption, not deliberate tampering**; there is no signing or proof of authorship, training history, quality or fairness. Schema, samples and training-environment declarations are not authenticated. Native parsers/inference require process isolation, memory/CPU/row limits and -maintained dependencies. See [SECURITY.md](SECURITY.md) for guarantees and reporting. +maintained dependencies. See [SECURITY.md](https://github.com/UlloaSP/mlbundle/blob/main/SECURITY.md) for guarantees and reporting. ## Development @@ -288,8 +289,9 @@ versions; dependency edits require `uv sync` and committing the updated lockfile Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, `uv run pytest` and `uv build`. Tests execute every fenced README example and write terminal/XML coverage with an 80% branch-inclusive minimum. -CI preserves lint/types and Ubuntu/Windows tests and checks a clean base wheel -installation on both OSes. `uv build` creates a local wheel and source archive. -Read [AGENTS.md](AGENTS.md) before contributing. Publishing, releases, version -tags, conversions, per-model environments, signing and additional model libraries -remain outside scope. The project is MIT licensed. +CI checks lint/types and tests the same built wheel on Ubuntu/Windows, including +clean base installs. The owner, or an agent on the owner's explicit request for +that version, runs manual [publishing](https://github.com/UlloaSP/mlbundle/blob/main/docs/publishing.md) from `main` with `pypi_env` approval. Read [AGENTS.md](https://github.com/UlloaSP/mlbundle/blob/main/AGENTS.md). +Agents never run or approve it on their own initiative or add publish paths, tokens or secrets. +Releases, version tags, conversions, per-model environments, signing and additional +model libraries remain outside scope. The project is MIT licensed. diff --git a/SECURITY.md b/SECURITY.md index 93c422c..1073721 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,8 @@ # Security policy -mlbundle v0.1 is pre-release; format version 1 is not yet frozen. It implements -strict manifest validation, secure container reading/writing, metadata-only +The published v0.1.0a1 pre-alpha has not had an independent security review; +do not accept bundles from untrusted parties. Format version 1 is not frozen. +It implements strict manifest validation, secure container reading/writing, metadata-only `inspect`, export/load, sklearn, XGBoost, CatBoost and ONNX adapters, local schema inference, explicit samples, prediction-state verification and the inspect/verify CLI. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..69b8691 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,59 @@ +# Manual pre-alpha publishing + +Version `0.1.0a1` reserves the PyPI name `mlbundle`. Format version 1 is not +frozen, so bundles may need re-export. The security design has not had an +independent review; do not accept bundles from untrusted parties. + +The owner, or an agent on the owner's explicit request for that specific version, +runs this workflow. Agents never run or approve it on their own initiative. +There is no API token, repository secret, tag, GitHub release or other publish path. + +## Publishing steps + +The owner, or an agent on the owner's explicit request, follows these steps: + +1. Review and merge the change into `develop`, then bring it into `main` through + the normal reviewed integration process. The workflow refuses any other ref. +2. Confirm the registered PyPI pending publisher at + [account publishing](https://pypi.org/manage/account/publishing/) has these + exact values: project `mlbundle`, owner `UlloaSP`, repository `mlbundle`, + workflow `publish.yml`, environment `pypi_env`. Confirm the GitHub `pypi_env` + environment still requires `UlloaSP` approval and permits only `main`. +3. Open [Publish to PyPI](https://github.com/UlloaSP/mlbundle/actions/workflows/publish.yml), + choose **Run workflow**, select **main**, and run it once. +4. Wait for the build/CI jobs to succeed. Review the run's commit and downloaded + `distributions` artifact, containing the `0.1.0a1` wheel and source archive. + Approve the `pypi_env` deployment when satisfied. This approval permits upload. +5. Check [mlbundle on PyPI](https://pypi.org/project/mlbundle/0.1.0a1/) and install + with `python -m pip install --pre "mlbundle[sklearn]"` on Python 3.14. + +A pending publisher does not reserve a name until a successful upload. The +[PyPI pending-publisher documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) +explains this first-upload step. If publishing fails or only one artifact uploads, +inspect the PyPI state before taking further action; the preflight rejects an +existing version and the workflow does not skip existing files. + +## What the workflow checks + +`publish.yml` has two jobs. Its `build` job calls `ci.yml` from the same commit, +with publishing preflight enabled. That preflight checks `refs/heads/main`, reads +the version from `pyproject.toml`, and requires HTTP 404 from the exact PyPI +version endpoint. An existing version, other HTTP errors or a network failure +stop the run. One concurrency group prevents overlapping publish runs. + +Reusable CI builds one wheel and source archive with Python 3.14, uv 0.12.19 and +`uv_build`. Strict Twine checks validate both artifacts' metadata and README +rendering. Both OSes check artifact contents, matching wheel/sdist package bytes, +and an isolated base-wheel install. Ubuntu and Windows install that exact wheel +and run the full test suite; lint and type checks must also succeed. Tests run +from the runner's temporary directory so CatBoost writes stay outside the checkout. + +The artifact is immutable within the run. `publish` waits for all reusable CI +jobs and `pypi_env` approval, downloads `distributions` and uploads those files without +rebuilding or checking out source. Only this job has `id-token: write`; the +workflow otherwise permits only `contents: read`. Trusted Publishing exchanges +OIDC identity for short-lived PyPI credentials through the documented +[PyPA publish action](https://github.com/pypa/gh-action-pypi-publish). + +Publication and OIDC authorization cannot be tested locally. The first authorized +upload is the verification of the registered publisher and approval gate. diff --git a/pyproject.toml b/pyproject.toml index 9c15a97..2e053f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mlbundle" -version = "0.1.0" +version = "0.1.0a1" description = "Pickle-free, verifiable model bundles with a manifest, schema and structure." readme = "README.md" license = "MIT" @@ -58,7 +58,7 @@ all = [ Homepage = "https://github.com/UlloaSP/mlbundle" Repository = "https://github.com/UlloaSP/mlbundle" Issues = "https://github.com/UlloaSP/mlbundle/issues" -Documentation = "https://github.com/UlloaSP/mlbundle/blob/develop/README.md" +Documentation = "https://github.com/UlloaSP/mlbundle/blob/main/README.md" [dependency-groups] dev = [ @@ -99,7 +99,8 @@ filterwarnings = [ source = ["mlbundle"] branch = true parallel = true -omit = ["*/tests/*", "*/__pycache__/*", "*/site-packages/*"] +# CI measures the installed wheel inside site-packages. +omit = ["*/tests/*", "*/__pycache__/*"] [tool.coverage.report] fail_under = 80 diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index d9c0201..e3b4de4 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -52,7 +52,7 @@ def _base(path: Path) -> None: import mlbundle - assert mlbundle.__version__ == "0.1.0" + assert mlbundle.__version__ == "0.1.0a1" view = mlbundle.inspect(path) assert view.manifest.model.library == "sklearn" assert "cannot attest" in _console("--help") @@ -119,14 +119,34 @@ def _contents(directory: Path) -> None: assert any(name.endswith(".dist-info/licenses/LICENSE") for name in wheel_names) assert any(name.endswith(".dist-info/entry_points.txt") for name in wheel_names) assert all(name.startswith(("mlbundle/", "mlbundle-")) for name in wheel_names) + wheel_files = { + name: archive.read(name) + for name in wheel_names + if name.startswith("mlbundle/") and not name.endswith("/") + } with open_tar(sdist) as archive: sdist_names = archive.getnames() assert any(name.endswith("/src/mlbundle/py.typed") for name in sdist_names) assert any(name.endswith("/LICENSE") for name in sdist_names) + prefix = sdist.name.removesuffix(".tar.gz") + "/src/" + sdist_files = {} + for member in archive.getmembers(): + if member.isfile() and member.name.startswith(prefix): + stream = archive.extractfile(member) + assert stream is not None + sdist_files[member.name.removeprefix(prefix)] = stream.read() + assert wheel_files == sdist_files, "Wheel and sdist package bytes must match" for name in [*wheel_names, *sdist_names]: - assert not set(Path(name).parts) & {"tests", ".venv", ".git", "__pycache__"}, ( - name - ) + assert not set(Path(name).parts) & { + "tests", + ".venv", + ".git", + "__pycache__", + "catboost_info", + ".pytest_cache", + ".ruff_cache", + "htmlcov", + }, name assert not name.endswith((".pyc", ".mlbundle", "coverage.xml")), name sys.stdout.write( f"artifacts: {wheel.name}, {sdist.name}; py.typed/catalog/license present; no tests or stray files\n" diff --git a/scripts/publish_check.py b/scripts/publish_check.py new file mode 100644 index 0000000..e778ab2 --- /dev/null +++ b/scripts/publish_check.py @@ -0,0 +1,34 @@ +"""Read-only preflight for the owner's manual PyPI workflow.""" + +import os +import sys +import tomllib +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + + +def check(ref: str, project: dict) -> None: + if ref != "refs/heads/main": + raise SystemExit("Publishing requires refs/heads/main; select main.") + version = project["version"] + url = f"https://pypi.org/pypi/mlbundle/{version}/json" + try: + with urlopen(url, timeout=30): + pass + except HTTPError as error: + error.close() + if error.code != 404: + raise SystemExit(f"PyPI version check failed: HTTP {error.code}.") from None + except (URLError, TimeoutError) as error: + raise SystemExit(f"PyPI version check failed: {error}.") from None + else: + raise SystemExit(f"mlbundle {version} already exists on PyPI; refusing upload.") + sys.stdout.write(f"mlbundle {version} is not on PyPI (HTTP 404); main ref OK.\n") + + +if __name__ == "__main__": + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + check(os.environ["GITHUB_REF"], project) diff --git a/tests/test_environment.py b/tests/test_environment.py index bded544..ad521d3 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -20,7 +20,7 @@ def test_installed_environment(): def test_missing_distributions_and_onnx(monkeypatch): def version(name): if name == "mlbundle": - return "0.1.0" + return "0.1.0a1" raise metadata.PackageNotFoundError(name) monkeypatch.setattr(module.metadata, "version", version) @@ -31,7 +31,7 @@ def version(name): producer_version="", ) environment = module.capture_environment(onnx=graph) - assert environment.packages == {"mlbundle": "0.1.0"} + assert environment.packages == {"mlbundle": "0.1.0a1"} assert environment.onnx == graph diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d60837c..8d6ec2c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -18,6 +18,13 @@ def read(data: dict) -> Manifest: return Manifest.from_bytes(canonical_dumps(data)) +@pytest.mark.parametrize("writer_version", ["0.1.0", "0.1.0a1"]) +def test_writer_version_is_independent_of_reader(manifest_data, writer_version): + manifest_data["writer_version"] = writer_version + manifest_data["environment"]["packages"]["mlbundle"] = writer_version + assert read(manifest_data).writer_version == writer_version + + def test_round_trip_and_claim_projection(manifest_data): manifest = read(manifest_data) assert Manifest.from_bytes(manifest.to_bytes()) == manifest diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 0000000..f36194a --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,54 @@ +"""Manual publish preflight rejects other refs, existing versions and outages.""" + +import runpy +from contextlib import nullcontext +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "publish_check.py" + + +@pytest.fixture +def preflight(): + return runpy.run_path(str(SCRIPT))["check"] + + +@pytest.mark.parametrize("ref", ["refs/heads/develop", "refs/tags/v0.1.0a1", ""]) +def test_other_refs_fail_before_network(preflight, monkeypatch, ref): + def unexpected(*args, **kwargs): + raise AssertionError("Network must not be used for other refs") + + monkeypatch.setitem(preflight.__globals__, "urlopen", unexpected) + with pytest.raises(SystemExit, match="requires refs/heads/main"): + preflight(ref, {"version": "0.1.0a1"}) + + +@pytest.mark.parametrize("code", [200, 404, 403, 429, 500]) +def test_exact_version_response(preflight, monkeypatch, capsys, code): + def response(url, timeout): + assert url == "https://pypi.org/pypi/mlbundle/0.1.0a1/json" + assert timeout == 30 + if code != 200: + raise HTTPError(url, code, "response", None, None) + return nullcontext() + + monkeypatch.setitem(preflight.__globals__, "urlopen", response) + if code == 404: + preflight("refs/heads/main", {"version": "0.1.0a1"}) + assert "HTTP 404" in capsys.readouterr().out + else: + message = "already exists" if code == 200 else f"HTTP {code}" + with pytest.raises(SystemExit, match=message): + preflight("refs/heads/main", {"version": "0.1.0a1"}) + + +@pytest.mark.parametrize("error", [URLError("offline"), TimeoutError("timeout")]) +def test_network_failure_is_closed(preflight, monkeypatch, error): + def response(*args, **kwargs): + raise error + + monkeypatch.setitem(preflight.__globals__, "urlopen", response) + with pytest.raises(SystemExit, match="PyPI version check failed"): + preflight("refs/heads/main", {"version": "0.1.0a1"}) diff --git a/tests/test_writing.py b/tests/test_writing.py index 7031d13..8e42e92 100644 --- a/tests/test_writing.py +++ b/tests/test_writing.py @@ -163,30 +163,30 @@ def test_chunked_model_and_empty_model(manifest_data): [ ( b"native model bytes", - "7349202ed42da9cbf06588566b130261bc2e09ba11a7193ae3fa3d561079e773", + "8e4bea2f61364aa5247df42588f6ad89065bcf87dc65a96d0cc185b602b27fe8", ), ( random.Random(0).randbytes(200_000), - "96bad29c8a6736e010e6fd0a2b80413bb6b1c716e9970770d7e1b2b514ae368c", + "f32df46fe39f515d09403af12a2e64bbee1fe4c579ad4229c14f187d397eb473", ), ( b"hello world " * 10_000, - "11b89928584fa5eac0950d48cffcdbd77b2d42ead5a36b7b381c2f4e512ffa36", + "2cfab1749bdca7b97cfd6eba990e16054f4b7404ac8a8a2c3a818c95d04150c8", ), ( bytes(range(256)) * 500, - "e9d931bd6cb9e0e77b9fafc66f28c2065d720167e0032f5f0f3ceff82a949014", + "a2d903caee80044799e2640cd5672e26c2fe13d0a0eddf1a5a9d061f12b9c942", ), ( b"0" * 1000, - "cf5bbd36f140cd2af5feffca7c4c67b288120bb44d33e558d927520a21f23eb7", + "5e92b884593597b822b7a72326b06084fe4c85ee028c164869115895f52dbc41", ), ], ids=["native", "noise", "text", "uniform", "runs"], ) def test_fixed_archive_bytes_on_both_platforms(manifest_data, data, expected): - manifest_data["writer_version"] = "0.1.0" - manifest_data["environment"]["packages"]["mlbundle"] = "0.1.0" + manifest_data["writer_version"] = "0.1.0a1" + manifest_data["environment"]["packages"]["mlbundle"] = "0.1.0a1" bundle = write_container(manifest_data, {MemberName.SKOPS: data}) assert hashlib.sha256(bundle).hexdigest() == expected diff --git a/uv.lock b/uv.lock index a237765..251f4eb 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "mlbundle" -version = "0.1.0" +version = "0.1.0a1" source = { editable = "." } dependencies = [ { name = "pydantic" },