chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
name: Bug Report (Use "UI Bug Report" for UI bugs)
|
||||
description: Create a report to help us reproduce and correct the bug
|
||||
labels: "bug"
|
||||
title: "[BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for additional information about bug reports. For help with debugging your code, please refer to [Stack Overflow](https://stackoverflow.com/questions/tagged/mlflow).
|
||||
#### Please fill in this bug report template to ensure a timely and thorough response.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Issues Policy acknowledgement
|
||||
description: |
|
||||
I understand that failure to adhere to the issues guidance may result in my issue being closed without warning or response.
|
||||
options:
|
||||
- label: I have read and agree to submit bug reports in accordance with the [issues policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md)
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Where did you encounter this bug?
|
||||
options:
|
||||
- Local machine
|
||||
- Databricks
|
||||
- Azure Machine Learning
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: MLflow version
|
||||
description: MLflow version (run `mlflow --version`) or commit SHA if you have MLflow installed from source (run `pip freeze | grep mlflow`). The tracking server version is required if `mlflow server` is used.
|
||||
value: |
|
||||
- Client: 1.x.y
|
||||
- Tracking server: 1.x.y
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the bug.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **Python version**:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Describe the problem clearly here. Include descriptions of the expected behavior and the actual behavior.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Tracking information
|
||||
description: |
|
||||
For bugs related to the tracking features (e.g. mlflow should log a run in my database but it doesn't), please insert the following code in your python script / notebook where you encountered the bug and run it:
|
||||
```python
|
||||
# MLflow < 2.0
|
||||
print("MLflow version:", mlflow.__version__)
|
||||
print("Tracking URI:", mlflow.get_tracking_uri())
|
||||
print("Artifact URI:", mlflow.get_artifact_uri())
|
||||
|
||||
# MLflow >= 2.0
|
||||
mlflow.doctor()
|
||||
```
|
||||
Then, make sure the printed out information matches what you expect and paste it (with sensitive information masked) in the box below. If you know the command that was used to launch your tracking server (e.g. `mlflow server -h 0.0.0.0 -p 5000`), please provide it.
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```shell
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to reproduce issue
|
||||
description: |
|
||||
Provide a reproducible test case that is the bare minimum necessary to generate the problem.
|
||||
|
||||
### Bad
|
||||
|
||||
Requires modifications (e.g., adding missing import statements) to run.
|
||||
|
||||
```python
|
||||
with mlflow.start_run(): # `mlflow` is not imported
|
||||
mlflow.sklearn.log_model(model, "model") # `model` is undefined
|
||||
```
|
||||
|
||||
### Good
|
||||
|
||||
Does not require any modifications to run.
|
||||
|
||||
```python
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
import mlflow
|
||||
|
||||
X, y = load_iris(return_X_y=True)
|
||||
model = LogisticRegression().fit(X, y)
|
||||
with mlflow.start_run():
|
||||
mlflow.sklearn.log_model(model, "model")
|
||||
```
|
||||
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Stack trace
|
||||
description: |
|
||||
Provide a **full** stack trace.
|
||||
|
||||
### Bad
|
||||
|
||||
```python
|
||||
TypeError: expected string or bytes-like object
|
||||
```
|
||||
|
||||
### Good
|
||||
|
||||
```python
|
||||
Traceback (most recent call last):
|
||||
File "a.py", line 3, in <module>
|
||||
mlflow.log_param(1, 2)
|
||||
File "/home/user/mlflow/mlflow/tracking/fluent.py", line 541, in log_param
|
||||
return MlflowClient().log_param(run_id, key, value)
|
||||
File "/home/user/mlflow/mlflow/tracking/client.py", line 742, in log_param
|
||||
self._tracking_client.log_param(run_id, key, value)
|
||||
File "/home/user/mlflow/mlflow/tracking/_tracking_service/client.py", line 295, in log_param
|
||||
self.store.log_param(run_id, param)
|
||||
File "/home/user/mlflow/mlflow/store/tracking/file_store.py", line 917, in log_param
|
||||
_validate_param(param.key, param.value)
|
||||
File "/home/user/mlflow/mlflow/utils/validation.py", line 150, in _validate_param
|
||||
_validate_param_name(key)
|
||||
File "/home/user/mlflow/mlflow/utils/validation.py", line 217, in _validate_param_name
|
||||
if not _VALID_PARAM_AND_METRIC_NAMES.match(name):
|
||||
TypeError: expected string or bytes-like object
|
||||
```
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other info / logs
|
||||
description: |
|
||||
Include any logs or source code that would be helpful to diagnose the problem. Large logs and files should be attached.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
# Tracking server logs
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Starting gunicorn 20.1.0
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Listening at: http://127.0.0.1:5000 (222636)
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Using worker: sync
|
||||
[2022-08-01 16:03:02 +0900] [222639] [INFO] Booting worker with pid: 222639
|
||||
```
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: component
|
||||
attributes:
|
||||
label: What component(s) does this bug affect?
|
||||
description: Please choose one or more components below.
|
||||
options:
|
||||
- label: "`area/tracking`: Tracking Service, tracking client APIs, autologging"
|
||||
required: false
|
||||
- label: "`area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry"
|
||||
required: false
|
||||
- label: "`area/scoring`: MLflow model serving, deployment tools, Spark UDFs"
|
||||
required: false
|
||||
- label: "`area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows"
|
||||
required: false
|
||||
- label: "`area/prompt`: MLflow prompt engineering features, prompt templates, and prompt management"
|
||||
required: false
|
||||
- label: "`area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality"
|
||||
required: false
|
||||
- label: "`area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations"
|
||||
required: false
|
||||
- label: "`area/projects`: MLproject format, project running backends"
|
||||
required: false
|
||||
- label: "`area/uiux`: Front-end, user experience, plotting"
|
||||
required: false
|
||||
- label: "`area/docs`: MLflow documentation pages"
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Documentation Fix
|
||||
description: Use this template for proposing documentation fixes/improvements.
|
||||
labels: "area/docs"
|
||||
title: "[DOC-FIX]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for information on what types of issues we address.
|
||||
|
||||
**Is this the right repository?**
|
||||
- ✅ **Documentation pages** under https://mlflow.org/docs (API reference, tutorials, how-to guides) — you're in the right place!
|
||||
- ❌ **Main website content** (blog posts, marketing pages, other site content) — please file at https://github.com/mlflow/mlflow-website/issues instead
|
||||
|
||||
**Important note about documentation branches:**
|
||||
The production documentation at https://mlflow.org/docs/latest/ is built from the **release branch** (e.g., `branch-3.7`), not `master`. If you notice a discrepancy between the live docs and the source code on `master`, the fix may already exist on `master`. You can verify this by checking the preview site at https://dev--mlflow-docs-preview.netlify.app/docs/latest which is built from `master`.
|
||||
|
||||
**Please fill in this documentation issue template to ensure a timely and thorough response.**
|
||||
- type: dropdown
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Willingness to contribute
|
||||
description: The MLflow Community encourages documentation fix contributions. Would you or another member of your organization be willing to contribute a fix for this documentation issue to the MLflow code base?
|
||||
options:
|
||||
- Yes. I can contribute a documentation fix independently.
|
||||
- Yes. I would be willing to contribute a document fix with guidance from the MLflow community.
|
||||
- No. I cannot contribute a documentation fix at this time.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: URL(s) with the issue
|
||||
description: |
|
||||
Please provide a link to the documentation entry in question.
|
||||
Note: This repo is for https://mlflow.org/docs pages only. For other website content, use https://github.com/mlflow/mlflow-website/issues.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description of proposal (what needs changing)
|
||||
description: |
|
||||
Provide a clear description. Why is the proposed documentation better?
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Feature Request
|
||||
description: Use this template for feature and enhancement proposals.
|
||||
labels: "enhancement"
|
||||
title: "[FR]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a feature request. **Before proceeding, please review MLflow's [Issue Policy for feature requests](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md#feature-requests) and the [MLflow Contributing Guide](https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md)**.
|
||||
**Please fill in this feature request template to ensure a timely and thorough response.**
|
||||
- type: dropdown
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Willingness to contribute
|
||||
description: The MLflow Community encourages new feature contributions. Would you or another member of your organization be willing to contribute an implementation of this feature (either as an MLflow Plugin or an enhancement to the MLflow code base)?
|
||||
options:
|
||||
- Yes. I can contribute this feature independently.
|
||||
- Yes. I would be willing to contribute this feature with guidance from the MLflow community.
|
||||
- No. I cannot contribute this feature at this time.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposal Summary
|
||||
description: |
|
||||
In a few sentences, provide a clear, high-level description of the feature request
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: |
|
||||
- What is the use case for this feature?
|
||||
- Why is this use case valuable to support for MLflow users in general?
|
||||
- Why is this use case valuable to support for your project(s) or organization?
|
||||
- Why is it currently difficult to achieve this use case? (please be as specific as possible about why related MLflow features and components are insufficient)
|
||||
value: |
|
||||
> #### What is the use case for this feature?
|
||||
|
||||
> #### Why is this use case valuable to support for MLflow users in general?
|
||||
|
||||
> #### Why is this use case valuable to support for your project(s) or organization?
|
||||
|
||||
> #### Why is it currently difficult to achieve this use case?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: |
|
||||
Use this section to include any additional information about the feature. If you have a proposal for how to implement this feature, please include it here. For implementation guidelines, please refer to the [Contributing Guide](https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#contribution-guidelines).
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: domain
|
||||
attributes:
|
||||
label: What machine learning domain(s) is this feature request about?
|
||||
description: Please choose one or more domains below.
|
||||
options:
|
||||
- label: "`domain/genai`: LLMs, Agents, and other GenAI-related use cases"
|
||||
required: false
|
||||
- label: "`domain/classical-ml`: Traditional machine learning, such as linear regression."
|
||||
required: false
|
||||
- label: "`domain/deep-learning`: Deep learning and neural networks."
|
||||
required: false
|
||||
- label: "`domain/platform`: MLflow platform foundation, not specific to a particular machine learning domain."
|
||||
|
||||
- type: checkboxes
|
||||
id: component
|
||||
attributes:
|
||||
label: What area(s) of MLflow is this feature request about?
|
||||
description: Please choose one or more components below.
|
||||
options:
|
||||
- label: "`area/tracking`: Tracking Service, tracking client APIs, autologging"
|
||||
required: false
|
||||
- label: "`area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry"
|
||||
required: false
|
||||
- label: "`area/scoring`: MLflow model serving, deployment tools, Spark UDFs"
|
||||
required: false
|
||||
- label: "`area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows"
|
||||
required: false
|
||||
- label: "`area/prompt`: MLflow prompt engineering features, prompt templates, and prompt management"
|
||||
required: false
|
||||
- label: "`area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality"
|
||||
required: false
|
||||
- label: "`area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations"
|
||||
required: false
|
||||
- label: "`area/projects`: MLproject format, project running backends"
|
||||
required: false
|
||||
- label: "`area/uiux`: Front-end, user experience, plotting"
|
||||
required: false
|
||||
- label: "`area/docs`: MLflow documentation pages"
|
||||
required: false
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Good First Issue
|
||||
description: "[Maintainer only] Use this template for issues that are good for first time contributors."
|
||||
labels: "good first issue"
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Summary
|
||||
description: |
|
||||
A summary of the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Notes
|
||||
value: |
|
||||
- Make sure to open a PR from a **non-master** branch.
|
||||
- Sign off the commit using the `-s` flag when making a commit:
|
||||
|
||||
```sh
|
||||
git commit -s -m "..."
|
||||
# ^^ make sure to use this
|
||||
```
|
||||
|
||||
- Include `#{issue_number}` (e.g. `#123`) in the PR description when opening a PR.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,62 @@
|
||||
name: Installation Issues
|
||||
description: Use this template for reporting bugs encountered while installing MLflow.
|
||||
labels: "bug"
|
||||
title: "[SETUP-BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for information on what types of issues we address.
|
||||
**Please fill in this installation issue template to ensure a timely and thorough response.**
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the installation issue.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **MLflow installed from (source or binary)**:
|
||||
- **MLflow version (run ``mlflow --version``)**:
|
||||
- **Python version**:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to reproduce issue
|
||||
description: |
|
||||
Provide a reproducible test case that is the bare minimum necessary to generate the problem.
|
||||
placeholder: |
|
||||
```bash
|
||||
pip install mlflow=x.y.z
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Provide the exact sequence of commands / steps that you executed before running into the problem.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other info / logs
|
||||
description: |
|
||||
Include any logs or source code that would be helpful to diagnose the problem. If including tracebacks, please include the full traceback. Large logs and files should be attached.
|
||||
placeholder: |
|
||||
```
|
||||
ERROR: Could not find a version that satisfies the requirement mlflow==x.y.z
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,122 @@
|
||||
name: UI Bug Report
|
||||
description: Create a report to help us reproduce and correct the UI bug
|
||||
labels: ["bug", "area/uiux"]
|
||||
title: "[BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for additional information about bug reports. For help with debugging your code, please refer to [Stack Overflow](https://stackoverflow.com/questions/tagged/mlflow).
|
||||
#### Please fill in this UI bug report template to ensure a timely and thorough response.
|
||||
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: MLflow version
|
||||
description: MLflow version (run `mlflow --version`) or commit SHA if you have MLflow installed from source (run `pip freeze | grep mlflow`).
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the bug.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **Python version**:
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Describe the problem clearly here. Include descriptions of the expected behavior and the actual behavior.
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Steps to reproduce the bug
|
||||
description: |
|
||||
**Record steps to reproduce the bug as a video or GIF** (to eliminate ambiguity) and attach it here.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to generate data required to reproduce the bug
|
||||
description: |
|
||||
Please provide code to generate data required to reproduce the bug.
|
||||
placeholder: |
|
||||
```python
|
||||
import mlflow
|
||||
|
||||
with mlflow.start_run():
|
||||
mlflow.log_param("p", 0)
|
||||
mlflow.log_metric("m", 1)
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Is the console panel in DevTools showing errors relevant to the bug? Type 'N/A' if not applicable.
|
||||
description: |
|
||||
If the console panel in your browser's DevTools is showing errors (displayed in red) relevant to the bug as shown in the screenshot below, please provide them as text (preferred) or a screenshot.
|
||||
|
||||
#### Instructions on how to use DevTools:
|
||||
- Chrome: [Chrome DevTools](https://developer.chrome.com/docs/devtools/)
|
||||
- Firefox: [Firefox DevTools User Docs](https://firefox-source-docs.mozilla.org/devtools-user/index.html)
|
||||
- Edge: [Overview of DevTools](https://docs.microsoft.com/en-us/microsoft-edge/devtools-guide-chromium/overview)
|
||||
|
||||

|
||||
<p align="center">Console panel on Chrome</P>
|
||||
placeholder: |
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'x')
|
||||
at n.value (HomeView.js:33:22)
|
||||
at za (react-dom.production.min.js:187:188)
|
||||
at Za (react-dom.production.min.js:186:173)
|
||||
at qs (react-dom.production.min.js:269:427)
|
||||
at Tl (react-dom.production.min.js:250:347)
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Does the network panel in DevTools contain failed requests relevant to the bug? Type 'N/A' if not applicable.
|
||||
description: |
|
||||
If the network panel in your browser's DevTools contain failed requests (displayed in red) relevant to the bug as shown in the screenshot below, please provide them as text (preferred) or a screenshot.
|
||||
|
||||

|
||||
<p align="center">Network panel on Chrome</P>
|
||||
placeholder: |
|
||||
```
|
||||
# Request URL
|
||||
http://localhost:5000/ajax-api/2.0/preview/mlflow/xxx/yyy
|
||||
|
||||
# Status Code
|
||||
400
|
||||
|
||||
# Payload
|
||||
{"a": 0}
|
||||
|
||||
# Response
|
||||
{"b": 1}
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
name: "cache-hf"
|
||||
description: "Cache HuggingFace Hub artifacts to avoid 429s in CI"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/huggingface/hub/datasets--*
|
||||
key: ${{ runner.os }}-hf-datasets-${{ hashFiles('tests/data/test_huggingface_dataset_and_source.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-hf-datasets
|
||||
@@ -0,0 +1,8 @@
|
||||
name: "check-component-ids"
|
||||
description: "Verify that all componentIds in the MLflow UI are registered in the componentId registry and vice versa."
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: ./.github/actions/setup-node
|
||||
- run: node ${GITHUB_ACTION_PATH}/index.js
|
||||
shell: bash
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
const { extractComponentIdsFromSource } = require("./utils");
|
||||
|
||||
const registry = require("./componentId-registry");
|
||||
|
||||
// --- Main ---
|
||||
const codeIds = extractComponentIdsFromSource(__dirname);
|
||||
const registryKeys = new Set(Object.keys(registry));
|
||||
|
||||
// Check 1: componentIds in code but not in registry
|
||||
const unregistered = [...codeIds].filter((id) => !registryKeys.has(id)).sort();
|
||||
|
||||
// Check 2: componentIds in registry but not in code (stale)
|
||||
const stale = [...registryKeys].filter((id) => !codeIds.has(id)).sort();
|
||||
|
||||
let failed = false;
|
||||
|
||||
if (unregistered.length > 0) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\n❌ Found ${unregistered.length} componentId(s) in code but NOT in the registry:\n`
|
||||
);
|
||||
for (const id of unregistered) {
|
||||
console.error(` + ${id}`);
|
||||
}
|
||||
console.error("\nAdd these to .github/actions/check-component-ids/componentId-registry.js");
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
failed = true;
|
||||
console.error(`\n❌ Found ${stale.length} stale componentId(s) in registry but NOT in code:\n`);
|
||||
for (const id of stale) {
|
||||
console.error(` - ${id}`);
|
||||
}
|
||||
console.error("\nRemove these from .github/actions/check-component-ids/componentId-registry.js");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`✅ componentId registry is in sync. ${registryKeys.size} entries verified.`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Regenerates the componentId registry from source code.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* node .github/actions/check-component-ids/regenerate.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { extractComponentIdsFromSource } = require("./utils");
|
||||
|
||||
const codeIds = extractComponentIdsFromSource(__dirname);
|
||||
const sorted = [...codeIds].sort();
|
||||
|
||||
// Group by prefix for readability
|
||||
const groups = {};
|
||||
for (const id of sorted) {
|
||||
let prefix;
|
||||
if (id.startsWith("codegen_")) {
|
||||
prefix = "Codegen (auto-generated)";
|
||||
} else if (id.startsWith("mlflow.")) {
|
||||
const parts = id.split(".");
|
||||
prefix = parts[0] + "." + parts[1];
|
||||
} else if (id.startsWith("shared.")) {
|
||||
const parts = id.split(".");
|
||||
prefix = parts[0] + "." + parts[1];
|
||||
} else {
|
||||
prefix = "Other";
|
||||
}
|
||||
if (!groups[prefix]) groups[prefix] = [];
|
||||
groups[prefix].push(id);
|
||||
}
|
||||
|
||||
// Load existing registry to preserve descriptions
|
||||
let existingDescriptions = {};
|
||||
try {
|
||||
existingDescriptions = require("./componentId-registry");
|
||||
} catch {
|
||||
// First run or broken registry — start fresh
|
||||
}
|
||||
|
||||
let output = `/**
|
||||
* Curated registry of all componentIds used in the MLflow UI.
|
||||
*
|
||||
* Every static componentId string literal in non-test source files must
|
||||
* have an entry here. The CI job \`check-component-ids\` verifies this
|
||||
* bidirectionally: code IDs must be in the registry, and registry
|
||||
* entries must exist in code.
|
||||
*
|
||||
* Format: key = componentId string, value = optional description of the
|
||||
* component (blank by default, especially for generated entries)
|
||||
*/
|
||||
module.exports = {\n`;
|
||||
|
||||
for (const gk of Object.keys(groups).sort()) {
|
||||
output += ` // -- ${gk} --\n`;
|
||||
for (const id of groups[gk]) {
|
||||
const escaped = id.replace(/"/g, '\\"');
|
||||
const desc = (existingDescriptions[id] || "").replace(/"/g, '\\"');
|
||||
output += ` "${escaped}": "${desc}",\n`;
|
||||
}
|
||||
output += "\n";
|
||||
}
|
||||
output += "};\n";
|
||||
|
||||
const outPath = path.join(__dirname, "componentId-registry.js");
|
||||
fs.writeFileSync(outPath, output);
|
||||
console.log(`✅ Registry regenerated with ${sorted.length} entries at ${outPath}`);
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];
|
||||
// Skip test files — they don't need registered componentIds
|
||||
const TEST_PATTERN = /\.test\.[jt]sx?$/;
|
||||
|
||||
const EXTRACT_PATTERNS = [
|
||||
/(?:componentId|data-component-id)=["']([^"']+)["']/g,
|
||||
/componentId:\s*["']([^"']+)["']/g,
|
||||
// Match static strings inside JSX expressions like componentId={"value"},
|
||||
// componentId={cond ?? "fallback"}, componentId={cond ? "a" : "b"}, etc.
|
||||
// Uses [^\n}]* to avoid matching across lines.
|
||||
/componentId=\{[^\n}]*["']([^"'\n`]+)["'][^\n}]*\}/g,
|
||||
];
|
||||
|
||||
function findFiles(dir) {
|
||||
const results = [];
|
||||
function walk(d) {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const full = path.join(d, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
||||
walk(full);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
EXTENSIONS.some((ext) => full.endsWith(ext)) &&
|
||||
!TEST_PATTERN.test(full)
|
||||
) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractComponentIds(files) {
|
||||
const ids = new Set();
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
for (const pat of EXTRACT_PATTERNS) {
|
||||
pat.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pat.exec(content)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all static componentIds from the MLflow UI source directory.
|
||||
* @param {string} actionDir - path to this action's directory (used to resolve the repo root)
|
||||
* @returns {Set<string>} set of componentId strings found in source
|
||||
*/
|
||||
function extractComponentIdsFromSource(actionDir) {
|
||||
const srcDir = path.resolve(
|
||||
process.env.GITHUB_WORKSPACE || path.join(actionDir, "../../.."),
|
||||
"mlflow/server/js/src"
|
||||
);
|
||||
const files = findFiles(srcDir);
|
||||
return extractComponentIds(files);
|
||||
}
|
||||
|
||||
module.exports = { extractComponentIdsFromSource };
|
||||
@@ -0,0 +1,9 @@
|
||||
name: "free-disk-space"
|
||||
description: "free disk space"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
# Run in background to save time
|
||||
sudo rm -rf /usr/local/lib/android &
|
||||
@@ -0,0 +1,27 @@
|
||||
name: "setup-java"
|
||||
description: "Set up Java"
|
||||
inputs:
|
||||
java-version:
|
||||
description: "java-version"
|
||||
default: "17"
|
||||
required: false
|
||||
|
||||
distribution:
|
||||
description: "distribution"
|
||||
default: temurin
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ inputs.java-version }}
|
||||
distribution: ${{ inputs.distribution }}
|
||||
|
||||
- name: Set MLFLOW_DOCKER_OPENJDK_VERSION
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_VERSION: ${{ inputs.java-version }}
|
||||
run: |
|
||||
echo "MLFLOW_DOCKER_OPENJDK_VERSION=$JAVA_VERSION" >> $GITHUB_ENV
|
||||
@@ -0,0 +1,19 @@
|
||||
name: "setup-node"
|
||||
description: "Set up Node"
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node version to use."
|
||||
default: "24"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
- run: |
|
||||
# allowScripts opt-in install-script policy requires npm >= 11.16.0
|
||||
# (https://github.com/npm/cli/pull/9360)
|
||||
npm install -g npm@"^11.16.0"
|
||||
shell: bash
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "setup-pyenv"
|
||||
description: "Setup pyenv"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
########## Ubuntu ##########
|
||||
- name: Install python build tools
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
# Ref: https://github.com/pyenv/pyenv/wiki#suggested-build-environment
|
||||
# Note: llvm is optional (required only for building PyPy/clang)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get purge -y man-db || true
|
||||
sudo apt-get install -y --no-install-recommends make build-essential libssl-dev zlib1g-dev \
|
||||
libbz2-dev libreadline-dev libsqlite3-dev wget curl \
|
||||
libncursesw5-dev xz-utils libffi-dev liblzma-dev
|
||||
- name: Install pyenv
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# Pin to pyenv v2.6.25 by tag + SHA verification for reproducible builds
|
||||
git clone --branch v2.6.25 --depth 1 https://github.com/pyenv/pyenv.git "$HOME/.pyenv"
|
||||
actual_sha=$(git -C "$HOME/.pyenv" rev-parse HEAD)
|
||||
expected_sha="aa2e8b82605b8ba085dd15f7a31fe1990fabdcfa"
|
||||
if [ "$actual_sha" != "$expected_sha" ]; then
|
||||
echo "::error::pyenv SHA mismatch: expected $expected_sha, got $actual_sha"
|
||||
exit 1
|
||||
fi
|
||||
- name: Setup environment variables
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
PYENV_ROOT="$HOME/.pyenv"
|
||||
PYENV_BIN="$PYENV_ROOT/bin"
|
||||
echo "$PYENV_BIN" >> $GITHUB_PATH
|
||||
echo "PYENV_ROOT=$PYENV_ROOT" >> $GITHUB_ENV
|
||||
- name: Check pyenv version
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
pyenv --version
|
||||
|
||||
########## Windows ##########
|
||||
- name: Install pyenv
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
uv pip install --system pyenv-win --target $HOME\\.pyenv
|
||||
- name: Setup environment variables
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "PYENV=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "PYENV_ROOT=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "PYENV_HOME=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "$USERPROFILE\.pyenv\pyenv-win\\bin\\" >> $GITHUB_PATH
|
||||
- name: Check pyenv version
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
pyenv --version
|
||||
@@ -0,0 +1,79 @@
|
||||
name: "setup-python"
|
||||
description: "Ensures to install a python version that's available on Anaconda"
|
||||
inputs:
|
||||
python-version:
|
||||
description: "The python version to install. If unspecified, install the minimum python version mlflow supports."
|
||||
required: false
|
||||
pin-micro-version:
|
||||
description: "Whether to pin to a specific micro version for Anaconda compatibility. Set to false for workflows that don't need conda/pyenv to hit the runner's pre-installed Python cache and avoid a ~9s download."
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
python-version:
|
||||
description: "The installed python version"
|
||||
value: ${{ steps.get-python-version.outputs.version }}
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: get-python-version
|
||||
id: get-python-version
|
||||
shell: bash
|
||||
# We used to use `conda search python=3.x` to dynamically fetch the latest available version
|
||||
# in 3.x on Anaconda, but it turned out `conda search` is very slow (takes 40 ~ 50 seconds).
|
||||
# This overhead sums up to a significant amount of delay in the cross version tests
|
||||
# where we trigger more than 100 GitHub Actions runs.
|
||||
env:
|
||||
PYTHON_VERSION_INPUT: ${{ inputs.python-version }}
|
||||
PIN_MICRO_VERSION: ${{ inputs.pin-micro-version }}
|
||||
run: |
|
||||
python_version="$PYTHON_VERSION_INPUT"
|
||||
if [ -z "$python_version" ]; then
|
||||
python_version=$(cat .python-version)
|
||||
fi
|
||||
if [[ "$PIN_MICRO_VERSION" == "true" ]]; then
|
||||
if [[ "$python_version" == "3.10" ]]; then
|
||||
if [ "$RUNNER_OS" == "Linux" ]; then
|
||||
python_version="3.10.20"
|
||||
else
|
||||
python_version="3.10.11"
|
||||
fi
|
||||
elif [[ "$python_version" == "3.11" ]]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
python_version="3.11.9"
|
||||
else
|
||||
python_version="3.11.15"
|
||||
fi
|
||||
elif [[ "$python_version" == "3.12" ]]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
python_version="3.12.10"
|
||||
else
|
||||
python_version="3.12.13"
|
||||
fi
|
||||
else
|
||||
echo "Invalid python version: '$python_version'. Must be '3.10', '3.11', or '3.12'."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "version=$python_version" >> $GITHUB_OUTPUT
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ steps.get-python-version.outputs.version }}
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
version: "0.11.14"
|
||||
# Caching disabled to avoid cache-poisoning exposure across CI workflows.
|
||||
enable-cache: false
|
||||
- run: |
|
||||
# The default `first-index` strategy is too strict. Use `unsafe-first-match` instead.
|
||||
# https://docs.astral.sh/uv/configuration/environment/#uv_index_strategy
|
||||
echo "UV_INDEX_STRATEGY=unsafe-first-match" >> $GITHUB_ENV
|
||||
# Disable progress bars and spinners to reduce CI log clutter
|
||||
# https://docs.astral.sh/uv/reference/environment/#uv_no_progress
|
||||
echo "UV_NO_PROGRESS=1" >> $GITHUB_ENV
|
||||
# Exclude packages newer than 7 days to guard against supply chain attacks
|
||||
# https://docs.astral.sh/uv/reference/environment/#uv_exclude_newer
|
||||
echo "UV_EXCLUDE_NEWER=P7D" >> $GITHUB_ENV
|
||||
# Disable Hugging Face download progress bars to reduce CI log clutter
|
||||
# https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables#hfhubdisableprogressbars
|
||||
echo "HF_HUB_DISABLE_PROGRESS_BARS=1" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
@@ -0,0 +1,21 @@
|
||||
name: "show-versions"
|
||||
description: "Show python package versions sorted by release date"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
# Activate the virtual environment if .venv exists
|
||||
if [ -d .venv ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
source .venv/Scripts/activate
|
||||
else
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
fi
|
||||
pip --disable-pip-version-check install ./dev/pypi > /dev/null
|
||||
echo ">>> package versions"
|
||||
status=0
|
||||
python dev/show_package_release_dates.py || status=$?
|
||||
echo "<<< package versions"
|
||||
exit $status
|
||||
@@ -0,0 +1,6 @@
|
||||
name: "untracked"
|
||||
description: "Detect untracked files"
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "index.js"
|
||||
post: "post.js"
|
||||
@@ -0,0 +1 @@
|
||||
// Does nothing
|
||||
@@ -0,0 +1,20 @@
|
||||
const { exec } = require("child_process");
|
||||
|
||||
exec("git ls-files --others --exclude-standard", (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`An error occurred: ${error}`);
|
||||
process.exit(error.code || 1);
|
||||
}
|
||||
|
||||
const untrackedFiles = stdout.trim();
|
||||
|
||||
if (untrackedFiles === "") {
|
||||
console.log("No untracked files found.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("Untracked files found:");
|
||||
console.log(untrackedFiles);
|
||||
console.log("Consider adding them to .gitignore.");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
name: "update-requirements"
|
||||
description: "Update requirements YAML specifications and re-generate requirements text files"
|
||||
|
||||
outputs:
|
||||
updated:
|
||||
description: "Indicates whether the requirements have been updated"
|
||||
value: ${{ steps.update-requirements.outputs.updated }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Update requirements
|
||||
id: update-requirements
|
||||
shell: bash --noprofile --norc -exo pipefail {0}
|
||||
run: |
|
||||
python bin/install.py
|
||||
python dev/update_requirements.py --requirements-yaml-location requirements
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "updated=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
python dev/pyproject.py
|
||||
git diff --color=always
|
||||
echo "updated=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
excludeAgent: ["coding-agent"]
|
||||
---
|
||||
|
||||
# Code Review Instructions
|
||||
|
||||
## Knowledge Cutoff
|
||||
|
||||
Your training data has a cutoff. Treat anything you don't recognize as **new, not wrong**. Here are things you might flag as incorrect but are actually valid:
|
||||
|
||||
- Model names (e.g., `gpt-5`)
|
||||
- GitHub runner types (e.g., `ubuntu-slim`)
|
||||
|
||||
## Comment Format (MANDATORY)
|
||||
|
||||
Every comment MUST use this exact format: `<emoji> **<severity>:** <description>`
|
||||
|
||||
| Severity | Emoji |
|
||||
| -------- | ----- |
|
||||
| CRITICAL | 🔴 |
|
||||
| MODERATE | 🟡 |
|
||||
| NIT | 🟢 |
|
||||
|
||||
Examples:
|
||||
|
||||
- 🔴 **CRITICAL:** User input is passed directly into the SQL query without parameterization — SQL injection risk. Use a parameterized query instead.
|
||||
- 🟡 **MODERATE:** This loops over each item and issues a separate query — N+1 problem. Use a single batch query or a join.
|
||||
- 🟢 **NIT:** This nested `if/elif/else` is hard to follow. Consider using early returns to flatten the structure.
|
||||
|
||||
## Do NOT Comment On
|
||||
|
||||
- Future dates, version numbers, model names, or runner types — your knowledge cutoff makes these unreliable
|
||||
- Discrepancies between PR description and code — focus on the code
|
||||
- Naming style preferences — only flag actively misleading names
|
||||
- Hypothetical or unlikely edge cases — if you'd write "while unlikely", "could potentially", or "edge case where", skip it. Only flag issues that realistically occur in practice.
|
||||
- Hardcoded values or magic numbers — do not suggest extracting constants for one-off values
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
applyTo: ".github/workflows/**/*.yml"
|
||||
---
|
||||
|
||||
# GitHub Actions Code Review Instructions
|
||||
|
||||
For workflow style conventions, see [.claude/rules/github-actions.md](../../.claude/rules/github-actions.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
applyTo: "**/*.py"
|
||||
---
|
||||
|
||||
# Python Code Review Instructions
|
||||
|
||||
For style conventions and code examples, see [.claude/rules/python.md](../../.claude/rules/python.md).
|
||||
@@ -0,0 +1,467 @@
|
||||
# regal ignore:directory-package-mismatch
|
||||
package mlflow
|
||||
|
||||
import rego.v1
|
||||
|
||||
deny_jobs_without_permissions contains msg if {
|
||||
jobs := jobs_without_permissions(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following jobs are missing permissions: %s",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_top_level_permissions contains msg if {
|
||||
# Workflow files only (composite actions have 'runs')
|
||||
input.jobs
|
||||
not input.permissions
|
||||
msg := concat("", [
|
||||
"Workflow must set top-level 'permissions: {}' to deny all by default. ",
|
||||
"Grant least-privilege permissions per job.",
|
||||
])
|
||||
}
|
||||
|
||||
deny_top_level_permissions contains msg if {
|
||||
input.jobs
|
||||
input.permissions != {}
|
||||
msg := "Top-level 'permissions' must be empty ({}). Grant least-privilege permissions per job instead."
|
||||
}
|
||||
|
||||
deny_unsafe_checkout contains msg if {
|
||||
# The "on" key gets transformed by conftest into "true" due to some legacy
|
||||
# YAML standards, see https://stackoverflow.com/q/42283732/2148786 - so
|
||||
# "on.push" becomes "true.push" which is why below statements use "true"
|
||||
# instead of "on".
|
||||
input["true"].pull_request_target
|
||||
not safe_pull_request_target_workflow
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/checkout@")
|
||||
step["with"].ref
|
||||
msg := concat("", [
|
||||
"Explicit checkout in a pull_request_target workflow is unsafe. ",
|
||||
"See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests for more information.",
|
||||
])
|
||||
}
|
||||
|
||||
# Workflows that are safe to use pull_request_target with explicit checkout
|
||||
# because they restrict execution to trusted authors via author_association.
|
||||
safe_pull_request_target_workflow if {
|
||||
input.name == "UI Preview"
|
||||
}
|
||||
|
||||
deny_create_app_token_without_permissions contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/create-github-app-token@")
|
||||
not step_has_app_token_permissions(step)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"actions/create-github-app-token in job '%s' must explicitly request permissions ",
|
||||
"via 'permission-<name>: <level>' inputs (e.g., permission-contents: write) for ",
|
||||
"least-privilege access. See ",
|
||||
"https://github.com/actions/create-github-app-token#create-a-token-with-specific-permissions",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_create_app_token_with_app_id contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/create-github-app-token@")
|
||||
step["with"]["app-id"]
|
||||
msg := sprintf(
|
||||
"actions/create-github-app-token in job '%s' uses deprecated 'app-id'. Use 'client-id' instead.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
step_has_app_token_permissions(step) if {
|
||||
some key, _ in step["with"]
|
||||
startswith(key, "permission-")
|
||||
}
|
||||
|
||||
deny_unnecessary_github_token contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{\s*(secrets\.GITHUB_TOKEN|github\.token)\s*\}\}`, step["with"]["github-token"])
|
||||
msg := "Unnecessary use of github-token for actions/github-script."
|
||||
}
|
||||
|
||||
deny_github_token_env_var contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
step.env.GITHUB_TOKEN
|
||||
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
||||
}
|
||||
|
||||
deny_github_token_env_var contains msg if {
|
||||
some job in input.jobs
|
||||
job.env.GITHUB_TOKEN
|
||||
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
some key, value in step["with"]
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step with.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
some key, value in step.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some key, value in job.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in job env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some key, value in input.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in top-level env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_jobs_without_timeout contains msg if {
|
||||
jobs := jobs_without_timeout(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following jobs are missing timeout-minutes: %s",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_ubuntu_slim_long_timeout contains msg if {
|
||||
jobs := ubuntu_slim_jobs_with_long_timeout(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following ubuntu-slim jobs have timeout-minutes > 15: %s. ubuntu-slim has a 15-minute timeout limit.",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_unpinned_actions contains msg if {
|
||||
actions := unpinned_actions(input)
|
||||
count(actions) > 0
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"The following actions are not pinned by full commit SHA: %s. ",
|
||||
"Use the full commit SHA instead ",
|
||||
"(e.g., actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683).",
|
||||
]),
|
||||
[concat(", ", actions)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_missing_shell_defaults contains msg if {
|
||||
# Only check workflow files (not composite actions)
|
||||
# Composite actions have 'runs' instead of 'jobs'
|
||||
input.jobs
|
||||
not input.defaults.run.shell
|
||||
msg := "Workflow must have 'defaults.run.shell: bash' to enable pipefail by default"
|
||||
}
|
||||
|
||||
deny_wrong_shell_defaults contains msg if {
|
||||
# Only check workflow files (not composite actions)
|
||||
input.jobs
|
||||
shell := input.defaults.run.shell
|
||||
shell != "bash"
|
||||
msg := sprintf(
|
||||
"Workflow has 'defaults.run.shell: %s' but it must be 'bash' to enable pipefail by default",
|
||||
[shell],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_script_without_retries contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
not step["with"].retries
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"actions/github-script in job '%s' must have 'retries' set ",
|
||||
"(e.g., retries: 3) for resilience against transient GitHub API failures.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_scheduled_workflow_without_repo_check contains msg if {
|
||||
input["true"].schedule
|
||||
not any_job_has_repo_check(input.jobs)
|
||||
msg := "Scheduled workflows must have at least one job with 'if: github.repository == ...' condition"
|
||||
}
|
||||
|
||||
deny_push_without_branches contains msg if {
|
||||
"push" in object.keys(input["true"])
|
||||
not is_object(input["true"].push)
|
||||
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
||||
}
|
||||
|
||||
deny_push_without_branches contains msg if {
|
||||
is_object(input["true"].push)
|
||||
not input["true"].push.branches
|
||||
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
||||
}
|
||||
|
||||
deny_interpolation_in_run contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
regex.match(`\$\{\{`, step.run)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in run block of job '%s'. ",
|
||||
"Use env: to pass the value and reference it as $VAR in the script.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_run contains msg if {
|
||||
not input.jobs
|
||||
input.runs.steps
|
||||
some i, step in input.runs.steps
|
||||
regex.match(`\$\{\{`, step.run)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in run block of composite action step #%d. ",
|
||||
"Use env: to pass the value and reference it as $VAR in the script.",
|
||||
]),
|
||||
[i + 1],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_github_script contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{`, step["with"].script)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in github-script of job '%s'. ",
|
||||
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_github_script contains msg if {
|
||||
not input.jobs
|
||||
input.runs.steps
|
||||
some i, step in input.runs.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{`, step["with"].script)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in github-script of composite action step #%d. ",
|
||||
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
||||
]),
|
||||
[i + 1],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_job_if contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
is_string(job["if"])
|
||||
regex.match(`\$\{\{`, job["if"])
|
||||
msg := sprintf(
|
||||
"Unnecessary ${{ }} in 'if' of job '%s'. Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_step_if contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
is_string(step["if"])
|
||||
regex.match(`\$\{\{`, step["if"])
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Unnecessary ${{ }} in 'if' of step '%s' in job '%s'. ",
|
||||
"Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
||||
]),
|
||||
[step.name, job_id],
|
||||
)
|
||||
}
|
||||
|
||||
contains_github_token(value) if {
|
||||
regex.match(`\$\{\{\s*github\.token\s*\}\}`, value)
|
||||
}
|
||||
|
||||
jobs_without_permissions(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
not job.permissions
|
||||
}
|
||||
|
||||
jobs_without_timeout(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
not job["timeout-minutes"]
|
||||
}
|
||||
|
||||
ubuntu_slim_jobs_with_long_timeout(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
job["runs-on"] == "ubuntu-slim"
|
||||
job["timeout-minutes"] > 15
|
||||
}
|
||||
|
||||
is_step_unpinned(step) if {
|
||||
not startswith(step.uses, "./")
|
||||
not regex.match(`^[^@]+@[0-9a-f]{40}$`, step.uses)
|
||||
}
|
||||
|
||||
unpinned_actions(inp) := unpinned if {
|
||||
# For workflow files with jobs
|
||||
inp.jobs
|
||||
unpinned := {step.uses |
|
||||
some job in inp.jobs
|
||||
some step in job.steps
|
||||
is_step_unpinned(step)
|
||||
}
|
||||
}
|
||||
|
||||
unpinned_actions(inp) := unpinned if {
|
||||
# For composite action files with runs
|
||||
not inp.jobs
|
||||
inp.runs.steps
|
||||
unpinned := {step.uses |
|
||||
some step in inp.runs.steps
|
||||
is_step_unpinned(step)
|
||||
}
|
||||
}
|
||||
|
||||
any_job_has_repo_check(jobs) if {
|
||||
some job in jobs
|
||||
job_has_repo_check(job)
|
||||
}
|
||||
|
||||
job_has_repo_check(job) if {
|
||||
regex.match(`github\.repository\s*==\s*'mlflow/`, job["if"])
|
||||
}
|
||||
|
||||
deny_secrets_in_top_level_env contains msg if {
|
||||
some key, value in input.env
|
||||
contains_secret(value)
|
||||
msg := sprintf(
|
||||
"Secret in top-level env.%s. Move secrets to step-level env for least-privilege scope.",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_secrets_in_job_level_env contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some key, value in job.env
|
||||
contains_secret(value)
|
||||
msg := sprintf(
|
||||
"Secret in job-level env.%s of job '%s'. Move secrets to step-level env for least-privilege scope.",
|
||||
[key, job_id],
|
||||
)
|
||||
}
|
||||
|
||||
contains_secret(value) if {
|
||||
regex.match(`\$\{\{\s*secrets\.`, value)
|
||||
}
|
||||
|
||||
deny_checkout_missing_persist_credentials contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/checkout@")
|
||||
not has_explicit_persist_credentials(step)
|
||||
msg := sprintf(
|
||||
"actions/checkout in job '%s' must set 'persist-credentials' explicitly (false for read-only, true if pushing).",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
has_explicit_persist_credentials(step) if {
|
||||
step["with"]["persist-credentials"] == false
|
||||
}
|
||||
|
||||
has_explicit_persist_credentials(step) if {
|
||||
step["with"]["persist-credentials"] == true
|
||||
}
|
||||
|
||||
deny_upload_artifact_without_retention contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/upload-artifact@")
|
||||
not step["with"]["retention-days"]
|
||||
msg := sprintf(
|
||||
"actions/upload-artifact in job '%s' must set 'retention-days' explicitly.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_upload_artifact_without_if_no_files_found contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/upload-artifact@")
|
||||
not step["with"]["if-no-files-found"]
|
||||
msg := sprintf(
|
||||
"actions/upload-artifact in job '%s' must set 'if-no-files-found' explicitly.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_matrix_without_fail_fast contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
job.strategy.matrix
|
||||
not has_explicit_fail_fast(job.strategy)
|
||||
msg := sprintf(
|
||||
"strategy.matrix in job '%s' must set 'fail-fast' explicitly (either true or false).",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
has_explicit_fail_fast(strategy) if {
|
||||
strategy["fail-fast"] == false
|
||||
}
|
||||
|
||||
has_explicit_fail_fast(strategy) if {
|
||||
strategy["fail-fast"] == true
|
||||
}
|
||||
|
||||
deny_mutable_install contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
some line in split(step.run, "\n")
|
||||
regex.match(`\bnpm install\b`, line)
|
||||
not regex.match(`--package-lock-only\b`, line)
|
||||
msg := sprintf(
|
||||
"'npm install' in job '%s' modifies the lockfile. Use 'npm ci' for reproducible builds.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_mutable_install contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
regex.match(`(?m)^\s*yarn(\s+install)?\s*(?:#.*)?$`, step.run)
|
||||
not regex.match(`\byarn install\s+--immutable\b`, step.run)
|
||||
msg := sprintf(
|
||||
"yarn or yarn install in job '%s' may modify the lockfile. Use 'yarn install --immutable'.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!--
|
||||
Do not remove any sections. Remove unused checkboxes except for the patch release section at the bottom.
|
||||
Use backticks for code references and file paths in the PR title (e.g., `ClassName`, `function_name`, `utils.py`).
|
||||
-->
|
||||
|
||||
### Related Issues/PRs
|
||||
|
||||
<!-- 🚨 Choose either "Closes" (auto-close on merge) or "Relates to" (reference only). 🚨 -->
|
||||
|
||||
Closes | Relates to #issue_number
|
||||
|
||||
### What changes are proposed in this pull request?
|
||||
|
||||
<!-- Please fill in changes proposed in this PR. -->
|
||||
|
||||
### How is this PR tested?
|
||||
|
||||
- [ ] Existing unit/integration tests
|
||||
- [ ] New unit/integration tests
|
||||
- [ ] Manual tests
|
||||
|
||||
<!-- Attach code, screenshot, video used for manual testing here. -->
|
||||
|
||||
### Does this PR require documentation update?
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. I've updated:
|
||||
- [ ] Examples
|
||||
- [ ] API references
|
||||
- [ ] Instructions
|
||||
|
||||
### Does this PR require updating the [MLflow Skills](https://github.com/mlflow/skills) repository?
|
||||
|
||||
<!-- When updating APIs or feature usage, please ensure the MLflow Skills repository reflects those changes. -->
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. Please link the corresponding PR or explain how you plan to update it.
|
||||
|
||||
<!-- Provide the link to the Skills repository PR or a brief explanation of the changes needed. -->
|
||||
|
||||
### Release Notes
|
||||
|
||||
#### Is this a user-facing change?
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. Give a description of this change to be included in the release notes for MLflow users.
|
||||
|
||||
<!-- Details in 1-2 sentences. You can just refer to another PR with a description if this PR is part of a larger change. -->
|
||||
|
||||
#### What component(s), interfaces, languages, and integrations does this PR affect?
|
||||
|
||||
Components
|
||||
|
||||
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging
|
||||
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
|
||||
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
|
||||
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
|
||||
- [ ] `area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows
|
||||
- [ ] `area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations
|
||||
- [ ] `area/prompts`: MLflow prompt engineering features, prompt templates, and prompt management
|
||||
- [ ] `area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality
|
||||
- [ ] `area/projects`: MLproject format, project running backends
|
||||
- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
|
||||
- [ ] `area/build`: Build and test infrastructure for MLflow
|
||||
- [ ] `area/docs`: MLflow documentation pages
|
||||
|
||||
<!--
|
||||
Insert an empty named anchor here to allow jumping to this section with a fragment URL
|
||||
(e.g. https://github.com/mlflow/mlflow/pull/123#user-content-release-note-category).
|
||||
Note that GitHub prefixes anchor names in markdown with "user-content-".
|
||||
-->
|
||||
|
||||
<a name="release-note-category"></a>
|
||||
|
||||
#### How should the PR be classified in the release notes? Choose one:
|
||||
|
||||
- [ ] `rn/none` - No description will be included. The PR will be mentioned only by the PR number in the "Small Bugfixes and Documentation Updates" section
|
||||
- [ ] `rn/breaking-change` - The PR will be mentioned in the "Breaking Changes" section
|
||||
- [ ] `rn/feature` - A new user-facing feature worth mentioning in the release notes
|
||||
- [ ] `rn/bug-fix` - A user-facing bug fix worth mentioning in the release notes
|
||||
- [ ] `rn/documentation` - A user-facing documentation change worth mentioning in the release notes
|
||||
|
||||
#### Is this PR a critical bugfix or security fix that should go into the next patch release?
|
||||
|
||||
<details>
|
||||
<summary>What is a minor/patch release?</summary>
|
||||
|
||||
- Minor release: a release that increments the second part of the version number (e.g., 1.2.0 -> 1.3.0).
|
||||
Minor releases are expected to contain larger changes, such as new features and improvements. Non-critical bug fixes and doc updates can be included as well. By default, your PR should target the next minor release.
|
||||
- Patch release: a release that increments the third part of the version number (e.g., 1.2.0 -> 1.2.1).
|
||||
Patch releases are typically only performed when there has been a major regression or bug in the latest release. For the sake of stability, your PR should not be included in a patch release unless it is a critical fix, or if the risk level of your PR is exceedingly low.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- Do not modify or remove any text inside the parentheses. Keep both checkboxes below. -->
|
||||
|
||||
- [ ] This PR is critical and needs to be in the next patch release
|
||||
- [ ] This PR can wait for the next minor release
|
||||
@@ -0,0 +1,56 @@
|
||||
# UI Preview
|
||||
|
||||
Deploy a live preview of the MLflow UI as a [Databricks App](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/) when a PR modifies the frontend (`mlflow/server/js/`).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Add the `ui-preview` label to a PR with UI changes
|
||||
2. The [UI Preview workflow](../workflows/ui-preview.yml) builds the frontend and deploys it to a Databricks App
|
||||
3. A comment with the preview URL is posted on the PR
|
||||
4. The app is automatically deleted when the PR is closed
|
||||
|
||||
## Access
|
||||
|
||||
Preview apps are only accessible to core maintainers with workspace access.
|
||||
|
||||
## API access
|
||||
|
||||
To query or add data to a preview app, set the following environment variables:
|
||||
|
||||
```bash
|
||||
export DATABRICKS_HOST="https://..."
|
||||
export DATABRICKS_CLIENT_ID="..."
|
||||
export DATABRICKS_CLIENT_SECRET="..."
|
||||
export APP_URL="..."
|
||||
```
|
||||
|
||||
Then, obtain an access token:
|
||||
|
||||
```bash
|
||||
export TOKEN=$(curl -s -X POST "$DATABRICKS_HOST/oidc/v1/token" \
|
||||
-d "grant_type=client_credentials&client_id=$DATABRICKS_CLIENT_ID&client_secret=$DATABRICKS_CLIENT_SECRET&scope=all-apis" \
|
||||
| jq -r '.access_token')
|
||||
```
|
||||
|
||||
Once the token is obtained, run the following command to verify it works:
|
||||
|
||||
```bash
|
||||
curl -s "$APP_URL/api/2.0/mlflow/experiments/search" \
|
||||
-X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"max_results": 10}' | jq .
|
||||
```
|
||||
|
||||
You can also use the MLflow Python client:
|
||||
|
||||
```bash
|
||||
export MLFLOW_TRACKING_URI="$APP_URL"
|
||||
export MLFLOW_TRACKING_TOKEN="$TOKEN"
|
||||
```
|
||||
|
||||
```python
|
||||
import mlflow
|
||||
|
||||
mlflow.search_experiments(max_results=10)
|
||||
```
|
||||
|
||||
See [Connect to Databricks Apps](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/connect-local) for more details on authentication.
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mlflow.server
|
||||
from mlflow.demo import generate_all_demos
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup():
|
||||
# Extract UI build assets into the mlflow package's expected location
|
||||
tar_path = Path(__file__).parent.resolve() / "build.tar.gz"
|
||||
target_dir = Path(mlflow.server.__file__).parent / "js"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_logger.info("Extracting UI assets to %s", target_dir)
|
||||
subprocess.check_call(["tar", "xzf", tar_path, "-C", target_dir])
|
||||
|
||||
# Generate demo data. Always refresh so the preview app reflects the latest
|
||||
# demo content (e.g. new trace types) even if the SQLite database persisted
|
||||
# from a previous deploy with stale demo data.
|
||||
os.environ["MLFLOW_TRACKING_URI"] = "sqlite:///mlflow.db"
|
||||
_logger.info("Generating demo data...")
|
||||
generate_all_demos(refresh=True)
|
||||
_logger.info("Demo data generated.")
|
||||
|
||||
|
||||
def main():
|
||||
setup()
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mlflow",
|
||||
"server",
|
||||
"--backend-store-uri",
|
||||
"sqlite:///mlflow.db",
|
||||
"--default-artifact-root",
|
||||
"./mlartifacts",
|
||||
"--serve-artifacts",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8000",
|
||||
"--workers",
|
||||
"1",
|
||||
]
|
||||
_logger.info("Starting MLflow server: %s", " ".join(cmd))
|
||||
os.execvp(cmd[0], cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
command:
|
||||
- "python"
|
||||
- "app.py"
|
||||
env:
|
||||
- name: MLFLOW_SERVER_CORS_ALLOWED_ORIGINS
|
||||
value: "__APP_URL__"
|
||||
- name: MLFLOW_SERVER_ALLOWED_HOSTS
|
||||
value: "*"
|
||||
- name: MLFLOW_CRYPTO_KEK_PASSPHRASE
|
||||
value: "__KEK_PASSPHRASE__"
|
||||
@@ -0,0 +1,190 @@
|
||||
const ACTIVITY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const MAX_REPOS_TO_DISPLAY = 10;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getRecentActivity(github, username) {
|
||||
const windowStart = new Date(Date.now() - ACTIVITY_WINDOW_MS);
|
||||
const dateString = windowStart.toISOString().slice(0, 10);
|
||||
const query = `type:pr author:${username} created:>${dateString}`;
|
||||
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
});
|
||||
const repoCounts = new Map();
|
||||
for (const item of items) {
|
||||
const repoFullName = item.repository_url.replace("https://api.github.com/repos/", "");
|
||||
if (!repoCounts.has(repoFullName)) {
|
||||
repoCounts.set(repoFullName, { open: 0, closed: 0, merged: 0 });
|
||||
}
|
||||
const counts = repoCounts.get(repoFullName);
|
||||
if (item.pull_request?.merged_at) {
|
||||
counts.merged++;
|
||||
} else if (item.state === "closed") {
|
||||
counts.closed++;
|
||||
} else {
|
||||
counts.open++;
|
||||
}
|
||||
}
|
||||
return { totalPRs: items.length, repoCount: repoCounts.size, repoBreakdown: repoCounts };
|
||||
}
|
||||
|
||||
async function getRecentActivitySection(github, username) {
|
||||
const { totalPRs, repoCount, repoBreakdown } = await getRecentActivity(github, username);
|
||||
if (totalPRs === 0) {
|
||||
return "";
|
||||
}
|
||||
const prLabel = totalPRs === 1 ? "PR" : "PRs";
|
||||
const repoLabel = repoCount === 1 ? "repo" : "repos";
|
||||
const total = ({ open, closed, merged }) => open + closed + merged;
|
||||
const sortedRepos = [...repoBreakdown.entries()]
|
||||
.sort((a, b) => total(b[1]) - total(a[1]))
|
||||
.slice(0, MAX_REPOS_TO_DISPLAY);
|
||||
const tableRows = sortedRepos
|
||||
.map(
|
||||
([repo, counts]) =>
|
||||
`| [${repo}](https://github.com/${repo}/pulls/${username}) | ${counts.open} | ${
|
||||
counts.closed
|
||||
} | ${counts.merged} | ${total(counts)} |`
|
||||
)
|
||||
.join("\n");
|
||||
const topNote = repoCount > MAX_REPOS_TO_DISPLAY ? ` (showing top ${MAX_REPOS_TO_DISPLAY})` : "";
|
||||
return `
|
||||
<details><summary>PR author's recent activity</summary>
|
||||
|
||||
In the last 14 days, @${username} opened **${totalPRs} ${prLabel}** across **${repoCount} ${repoLabel}**${topNote}:
|
||||
|
||||
| Repository | Open | Closed | Merged | Total |
|
||||
| ---------- | ---- | ------ | ------ | ----- |
|
||||
${tableRows}
|
||||
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function getDcoCheck(github, owner, repo, sha) {
|
||||
const backoffs = [0, 2, 4, 6, 8];
|
||||
const numAttempts = backoffs.length;
|
||||
for (const [index, backoff] of backoffs.entries()) {
|
||||
await sleep(backoff * 1000);
|
||||
const resp = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
app_id: 1861, // ID of the DCO check app
|
||||
});
|
||||
|
||||
const { check_runs } = resp.data;
|
||||
if (check_runs.length > 0 && check_runs[0].status === "completed") {
|
||||
return check_runs[0];
|
||||
}
|
||||
console.log(`[Attempt ${index + 1}/${numAttempts}]`, "The DCO check hasn't completed yet.");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { number: issue_number } = context.issue;
|
||||
const { sha, label } = context.payload.pull_request.head;
|
||||
const { user, body } = context.payload.pull_request;
|
||||
const messages = [];
|
||||
|
||||
const title = "Install mlflow from this PR";
|
||||
// Check if an install comment already exists
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
const installCommentExists = comments.some((comment) => comment.body.includes(title));
|
||||
|
||||
if (!installCommentExists) {
|
||||
let activitySection = "";
|
||||
const memberAssociations = ["MEMBER", "OWNER", "COLLABORATOR"];
|
||||
if (
|
||||
user.type !== "Bot" &&
|
||||
!memberAssociations.includes(context.payload.pull_request.author_association)
|
||||
) {
|
||||
try {
|
||||
activitySection = await getRecentActivitySection(github, user.login);
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch recent activity:", e);
|
||||
}
|
||||
}
|
||||
const devToolsComment = `
|
||||
<details><summary>${title}</summary>
|
||||
<p>
|
||||
|
||||
#### Install mlflow from this PR
|
||||
|
||||
\`\`\`bash
|
||||
# mlflow
|
||||
pip install git+https://github.com/mlflow/mlflow.git@refs/pull/${issue_number}/merge
|
||||
# mlflow-skinny
|
||||
pip install git+https://github.com/mlflow/mlflow.git@refs/pull/${issue_number}/merge#subdirectory=libs/skinny
|
||||
\`\`\`
|
||||
|
||||
For Databricks, use the following command:
|
||||
|
||||
\`\`\`bash
|
||||
%sh curl -LsSf https://raw.githubusercontent.com/mlflow/mlflow/HEAD/dev/install-skinny.sh | sh -s pull/${issue_number}/merge
|
||||
\`\`\`
|
||||
|
||||
</p>
|
||||
</details>
|
||||
${activitySection}
|
||||
`.trim();
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: devToolsComment,
|
||||
});
|
||||
}
|
||||
|
||||
// Exit early if the PR author is a bot
|
||||
if (user.type === "Bot") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dcoCheck = await getDcoCheck(github, owner, repo, sha);
|
||||
if (dcoCheck && dcoCheck.conclusion !== "success") {
|
||||
messages.push(
|
||||
"#### ❌ DCO check\n\n" +
|
||||
"The DCO check failed. " +
|
||||
`Please sign off your commit(s) by following the instructions [here](${dcoCheck.html_url}). ` +
|
||||
"See https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#sign-your-work for more " +
|
||||
"details."
|
||||
);
|
||||
}
|
||||
|
||||
if (label.endsWith(":master")) {
|
||||
messages.push(
|
||||
"#### ❌ PR branch check\n\n" +
|
||||
"This PR was filed from the master branch in your fork, which is not recommended " +
|
||||
"and may cause our CI checks to fail. Please close this PR and file a new PR from " +
|
||||
"a non-master branch."
|
||||
);
|
||||
}
|
||||
|
||||
if (!(body || "").includes("How should the PR be classified in the release notes?")) {
|
||||
messages.push(
|
||||
"#### ❌ Invalid PR template\n\n" +
|
||||
"The PR description is missing required sections. " +
|
||||
"Please use the [PR template](https://raw.githubusercontent.com/mlflow/mlflow/master/.github/pull_request_template.md)."
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
const body =
|
||||
`@${user.login} Thank you for the contribution! Could you fix the following issue(s)? Otherwise, this PR may be automatically closed.\n\n` +
|
||||
messages.join("\n\n");
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Advice
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: >
|
||||
!(github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: write # advice.js comments on PRs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/advice.js`
|
||||
);
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,109 @@
|
||||
async function getMaintainers({ github, context }) {
|
||||
const collaborators = await github.paginate(github.rest.repos.listCollaborators, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
return collaborators
|
||||
.filter(({ role_name }) => ["admin", "maintain"].includes(role_name))
|
||||
.map(({ login }) => login)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const EXEMPTION_RULES = [
|
||||
// Exemption for GenAI evaluation PRs.
|
||||
{
|
||||
authors: ["alkispoly-db", "AveshCSingh", "danielseong1", "smoorjani", "SomtochiUmeh", "xsh310"],
|
||||
allowedPatterns: [
|
||||
/^mlflow\/genai\//,
|
||||
/^tests\/genai\//,
|
||||
/^docs\//,
|
||||
/^mlflow\/entities\/(assessment|dataset|evaluation|scorer)/,
|
||||
],
|
||||
excludedPatterns: [/^mlflow\/genai\/(agent_server|git_versioning|prompts|optimize)\//],
|
||||
},
|
||||
// Exemption for UI PRs.
|
||||
{
|
||||
authors: ["daniellok-db", "danielseong1", "hubertzub-db"],
|
||||
allowedPatterns: [/^mlflow\/server\/js\//],
|
||||
},
|
||||
];
|
||||
|
||||
function matchesAnyPattern(path, patterns) {
|
||||
if (!patterns) {
|
||||
return false;
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
function isAllowedPath(path, rule) {
|
||||
return (
|
||||
matchesAnyPattern(path, rule.allowedPatterns) && !matchesAnyPattern(path, rule.excludedPatterns)
|
||||
);
|
||||
}
|
||||
|
||||
function isExempted(authorLogin, files) {
|
||||
let filesToCheck = files;
|
||||
for (const rule of EXEMPTION_RULES) {
|
||||
if (rule.authors.includes(authorLogin)) {
|
||||
filesToCheck = filesToCheck.filter(
|
||||
({ filename, previous_filename }) =>
|
||||
// Keep files where NOT all before/after file paths are allowed by the rule.
|
||||
![filename, previous_filename].filter(Boolean).every((path) => isAllowedPath(path, rule))
|
||||
);
|
||||
if (filesToCheck.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyApproval(reviews) {
|
||||
return reviews.some(({ state }) => state === "APPROVED");
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const { pull_request: pr } = context.payload;
|
||||
const authorLogin = pr?.user?.login;
|
||||
if (authorLogin === "mlflow-app[bot]") {
|
||||
return;
|
||||
}
|
||||
|
||||
const maintainers = await getMaintainers({ github, context });
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const APPROVED_BOTS = new Set(["nailaopus[bot]"]);
|
||||
const maintainerApproved = reviews.some(
|
||||
({ state, user }) =>
|
||||
state === "APPROVED" &&
|
||||
// GitHub returns `user: null` on reviews from accounts that have since
|
||||
// been deleted; skip them rather than crashing on `user.login`.
|
||||
user &&
|
||||
(maintainers.includes(user.login) ||
|
||||
(user.type.toLowerCase() === "bot" && APPROVED_BOTS.has(user.login)))
|
||||
);
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
|
||||
if (isExempted(authorLogin, files)) {
|
||||
if (!hasAnyApproval(reviews)) {
|
||||
core.setFailed(
|
||||
"PR from exempted author needs at least one approval (maintainer approval not required)."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maintainerApproved) {
|
||||
const maintainerList = maintainers.join(", ");
|
||||
const message = `This PR requires an approval from at least one of the core maintainers: ${maintainerList}.`;
|
||||
core.setFailed(message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Approval
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Fail without core maintainer approval
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/approval.js`);
|
||||
await script({ context, github, core });
|
||||
@@ -0,0 +1,184 @@
|
||||
async function getMaintainers({ github, context }) {
|
||||
const collaborators = await github.paginate(github.rest.repos.listCollaborators, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
return collaborators
|
||||
.filter(
|
||||
({ role_name, login }) =>
|
||||
["admin", "maintain"].includes(role_name) ||
|
||||
[
|
||||
"alkispoly-db",
|
||||
"AveshCSingh",
|
||||
"danielseong1",
|
||||
"smoorjani",
|
||||
"SomtochiUmeh",
|
||||
"xsh310",
|
||||
].includes(login)
|
||||
)
|
||||
.map(({ login }) => login)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function getLinkedIssues({ github, owner, repo, prNumber }) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
createdAt
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await github.graphql(query, {
|
||||
owner,
|
||||
repo,
|
||||
number: prNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
prCreatedAt: result.repository.pullRequest.createdAt,
|
||||
issues: result.repository.pullRequest.closingIssuesReferences.nodes,
|
||||
};
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function findFirstMaintainerInIssueComments({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
maintainers,
|
||||
}) {
|
||||
for await (const response of github.paginate.iterator(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
})) {
|
||||
for (const comment of response.data) {
|
||||
if (maintainers.has(comment.user.login)) {
|
||||
return comment.user.login;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findMaintainerFromLinkedIssue({ github, owner, repo, prNumber, maintainers }) {
|
||||
const { prCreatedAt, issues: linkedIssues } = await getLinkedIssues({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
});
|
||||
|
||||
if (linkedIssues.length !== 1 || !prCreatedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkedIssue = linkedIssues[0];
|
||||
const prCreatedDate = new Date(prCreatedAt);
|
||||
const issueCreatedDate = new Date(linkedIssue.createdAt);
|
||||
|
||||
if (prCreatedDate - issueCreatedDate > SEVEN_DAYS_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findFirstMaintainerInIssueComments({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber: linkedIssue.number,
|
||||
maintainers,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, skipAssignment = false }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const maintainers = new Set(await getMaintainers({ github, context }));
|
||||
|
||||
// Get current time minus 200 minutes to look for recent comments and PRs
|
||||
const lookbackTime = new Date(Date.now() - 200 * 60 * 1000);
|
||||
|
||||
// Use search API to find recently updated open PRs
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:open updated:>=${lookbackTime.toISOString()}`;
|
||||
const searchResults = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: searchQuery,
|
||||
sort: "updated",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
console.log(`Scanning ${searchResults.length} recently updated PRs`);
|
||||
|
||||
for (const pr of searchResults) {
|
||||
// Get recent comments and reviews
|
||||
const issueComments = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
since: lookbackTime.toISOString(),
|
||||
});
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Filter reviews by lookback time and extract authors
|
||||
const recentReviews = reviews.data.filter((r) => new Date(r.submitted_at) > lookbackTime);
|
||||
const commentAuthors = new Set([
|
||||
...issueComments.data.map((c) => c.user.login),
|
||||
...recentReviews.map((r) => r.user.login),
|
||||
]);
|
||||
|
||||
// Use Set operations to find maintainers to assign
|
||||
const prAuthor = pr.user.login;
|
||||
const currentAssignees = new Set(pr.assignees.map((a) => a.login));
|
||||
const excludeSet = new Set([prAuthor, ...currentAssignees]);
|
||||
|
||||
let maintainersToAssign = [...commentAuthors.intersection(maintainers).difference(excludeSet)];
|
||||
|
||||
// Fall back to linked issue comments if no maintainers found from recent PR activity
|
||||
if (maintainersToAssign.length === 0) {
|
||||
const maintainer = await findMaintainerFromLinkedIssue({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
maintainers,
|
||||
});
|
||||
if (maintainer && !excludeSet.has(maintainer)) {
|
||||
maintainersToAssign = [maintainer];
|
||||
}
|
||||
}
|
||||
|
||||
if (maintainersToAssign.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign maintainers
|
||||
if (!skipAssignment) {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
assignees: maintainersToAssign,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`${skipAssignment ? "[DRY RUN] Would assign" : "Assigned"} [${maintainersToAssign.join(
|
||||
", "
|
||||
)}] to PR #${pr.number}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Scan completed");
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Auto-assign maintainer
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 3 hours to check for new maintainer comments
|
||||
- cron: "0 */3 * * *"
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/auto-assign.yml
|
||||
- .github/workflows/auto-assign.js
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
scan-and-assign:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/auto-assign.js');
|
||||
const skipAssignment = context.eventName === 'pull_request';
|
||||
await script({ github, context, skipAssignment });
|
||||
@@ -0,0 +1,230 @@
|
||||
// Auto-close PRs based on linked-issue policy:
|
||||
// 1. PRs that attempt to close an issue without the "ready" label.
|
||||
// 2. PRs that don't link to any issue and change more than LOC_THRESHOLD
|
||||
// lines.
|
||||
// Skips PRs that reference multiple issues (ambiguous intent).
|
||||
// Only enforces on issues/PRs created on or after 2026-03-10.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const READY_LABEL = "ready";
|
||||
const PR_TEMPLATE_PATH = ".github/pull_request_template.md";
|
||||
// The date we introduced the "ready" label policy; skip older issues/PRs.
|
||||
const CUTOFF_DATE = new Date("2026-03-10T00:00:00Z");
|
||||
// PRs with more than this many LOC changed must link to an issue.
|
||||
const LOC_THRESHOLD = 100;
|
||||
|
||||
const QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
labels(first: 50) {
|
||||
nodes { name }
|
||||
}
|
||||
assignees(first: 10) {
|
||||
nodes { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function getTemplateHeadings() {
|
||||
const templatePath = path.join(process.env.GITHUB_WORKSPACE, PR_TEMPLATE_PATH);
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(templatePath, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^#+\s/.test(line));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read PR template at ${templatePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasIssueReference(body) {
|
||||
if (!body) return false;
|
||||
// Strip fenced and inline code blocks so references mentioned inside code
|
||||
// samples don't count.
|
||||
const stripped = body
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/~~~[\s\S]*?~~~/g, "")
|
||||
.replace(/`[^`\n]*`/g, "");
|
||||
// Match `#123`, `owner/repo#123`, or an issue/PR URL.
|
||||
const shortRef = /(?:[\w.-]+\/[\w.-]+)?#\d+/;
|
||||
const urlRef = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/;
|
||||
return shortRef.test(stripped) || urlRef.test(stripped);
|
||||
}
|
||||
|
||||
function getMissingHeadings(body, headings) {
|
||||
if (!body) return headings;
|
||||
const bodyLines = new Set(body.split("\n").map((line) => line.trim()));
|
||||
return headings.filter((h) => !bodyLines.has(h));
|
||||
}
|
||||
|
||||
async function isDatabricksAuthor({ github, context }) {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Check user profile for Databricks affiliation
|
||||
const { data: user } = await github.rest.users.getByUsername({ username: prAuthor });
|
||||
if ([user.company, user.email].some((v) => /databricks/i.test(v || ""))) return true;
|
||||
|
||||
// Check commit author emails for @databricks.com
|
||||
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return commits.some((c) => /@databricks\.com$/i.test(c.commit.author.email || ""));
|
||||
}
|
||||
|
||||
async function getCloseReason({ github, context }) {
|
||||
const association = context.payload.pull_request.author_association;
|
||||
if (["OWNER", "MEMBER", "COLLABORATOR"].includes(association)) return undefined;
|
||||
if (context.payload.pull_request.user.type === "Bot") return undefined;
|
||||
|
||||
if (await isDatabricksAuthor({ github, context })) {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
console.log(`PR author @${prAuthor} has Databricks affiliation. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Check that the PR body follows the PR template
|
||||
const templateHeadings = getTemplateHeadings();
|
||||
const prBody = context.payload.pull_request.body;
|
||||
const missingHeadings = getMissingHeadings(prBody, templateHeadings);
|
||||
const missingRatio = missingHeadings.length / templateHeadings.length;
|
||||
console.log(
|
||||
`PR #${prNumber} is missing ${missingHeadings.length}/${templateHeadings.length} template section(s).`
|
||||
);
|
||||
if (missingRatio > 0.5) {
|
||||
const missingList = missingHeadings.map((h) => `- ${h.replace(/^#+\s*/, "")}`).join("\n");
|
||||
return [
|
||||
"This PR was automatically closed because it does not follow the PR template.",
|
||||
`<details>\n<summary>Missing sections</summary>\n\n${missingList}\n</details>`,
|
||||
`Please update your PR body to include all sections from the [PR template](https://github.com/${owner}/${repo}/blob/master/${PR_TEMPLATE_PATH}) and reopen this PR.`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const response = await github.graphql(QUERY, { owner, repo, number: prNumber });
|
||||
const issues = response.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
|
||||
if (issues.length === 0) {
|
||||
// closingIssuesReferences only catches closing keywords (Fixes/Closes/Resolves).
|
||||
// Also accept `#123`, `owner/repo#123`, or an issue/PR URL in the PR body.
|
||||
if (hasIssueReference(prBody)) {
|
||||
console.log(`PR #${prNumber} body contains an issue reference. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prCreatedAt = new Date(context.payload.pull_request.created_at);
|
||||
if (prCreatedAt < CUTOFF_DATE) {
|
||||
console.log(`PR #${prNumber} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { additions, deletions } = context.payload.pull_request;
|
||||
const totalChanges = additions + deletions;
|
||||
|
||||
if (totalChanges <= LOC_THRESHOLD) {
|
||||
console.log(
|
||||
`PR #${prNumber} has no linked issue but only ${totalChanges} LOC changed (<= ${LOC_THRESHOLD}). Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`PR #${prNumber} has no linked issue and ${totalChanges} LOC changed (> ${LOC_THRESHOLD}). Closing.`
|
||||
);
|
||||
return [
|
||||
"This PR was automatically closed because it does not link to an issue.",
|
||||
"Please open an issue describing the bug or feature first, wait for a maintainer to triage it, then link it from your PR description (e.g. `Fixes #123`).",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
if (issues.length > 1) {
|
||||
console.log(
|
||||
`Multiple issues referenced (${issues.map((i) => `#${i.number}`).join(", ")}). Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const issue = issues[0];
|
||||
console.log(`PR #${prNumber} references issue #${issue.number}`);
|
||||
|
||||
// Skip issues created before the cutoff date
|
||||
if (new Date(issue.createdAt) < CUTOFF_DATE) {
|
||||
console.log(
|
||||
`Issue #${issue.number} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hasReadyLabel = issue.labels.nodes.some((label) => label.name === READY_LABEL);
|
||||
if (!hasReadyLabel) {
|
||||
console.log(
|
||||
`Issue #${issue.number} is missing the "${READY_LABEL}" label. Closing PR #${prNumber}.`
|
||||
);
|
||||
return [
|
||||
`This PR was automatically closed because #${issue.number} is missing the \`${READY_LABEL}\` label.`,
|
||||
"Once a maintainer triages the issue and applies the label, feel free to reopen this PR.",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
const assigneeLogins = issue.assignees.nodes.map((a) => a.login);
|
||||
if (assigneeLogins.length > 0 && !assigneeLogins.includes(prAuthor)) {
|
||||
const assigneeList = assigneeLogins.map((login) => `@${login}`).join(", ");
|
||||
console.log(
|
||||
`Issue #${issue.number} is assigned to ${assigneeList} but PR author is @${prAuthor}. Closing PR #${prNumber}.`
|
||||
);
|
||||
return [
|
||||
`This PR was automatically closed because #${issue.number} is assigned to ${assigneeList}.`,
|
||||
"If you believe this was done in error, please reach out to a maintainer.",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
console.log(`Issue #${issue.number} has the "${READY_LABEL}" label. No action needed.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function main({ context, github }) {
|
||||
const commentBody = await getCloseReason({ github, context });
|
||||
if (commentBody !== undefined) {
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
console.log(`PR #${prNumber} closed.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main, getCloseReason, isDatabricksAuthor };
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Auto Close PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-close-pr:
|
||||
if: >-
|
||||
!contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
&& github.event.pull_request.user.type != 'Bot'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/auto-close-pr.js`
|
||||
);
|
||||
await script.main({ context, github });
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Autoformat Label Notification
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-slim
|
||||
if: github.event.label.name == 'autoformat'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write # to post a comment on the PR
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr comment "$PR" --repo "$REPO" --body 'Please use `/autoformat` command instead of labels.'
|
||||
gh pr edit "$PR" --repo "$REPO" --remove-label autoformat
|
||||
@@ -0,0 +1,217 @@
|
||||
const createCommitStatus = async (context, github, sha, state) => {
|
||||
const { workflow, runId } = context;
|
||||
const { owner, repo } = context.repo;
|
||||
const target_url = `https://github.com/${owner}/${repo}/actions/runs/${runId}?pr=${context.issue.number}`;
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
state,
|
||||
target_url,
|
||||
description: sha,
|
||||
context: workflow,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldAutoformat = (comment) => {
|
||||
return comment.body.trim() === "/autoformat";
|
||||
};
|
||||
|
||||
const getPullInfo = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
const {
|
||||
sha: head_sha,
|
||||
ref: head_ref,
|
||||
repo: { full_name },
|
||||
} = pr.data.head;
|
||||
const { sha: base_sha, ref: base_ref, repo: base_repo } = pr.data.base;
|
||||
return {
|
||||
repository: full_name,
|
||||
pull_number,
|
||||
head_sha,
|
||||
head_ref,
|
||||
base_sha,
|
||||
base_ref,
|
||||
base_repo: base_repo.full_name,
|
||||
author_association: pr.data.author_association,
|
||||
};
|
||||
};
|
||||
|
||||
const createReaction = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { id: comment_id } = context.payload.comment;
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id,
|
||||
content: "rocket",
|
||||
});
|
||||
};
|
||||
|
||||
const createStatus = async (context, github, core) => {
|
||||
const { head_sha, head_ref, repository } = await getPullInfo(context, github);
|
||||
if (repository === "mlflow/mlflow" && head_ref === "master") {
|
||||
core.setFailed("Running autoformat bot against master branch of mlflow/mlflow is not allowed.");
|
||||
}
|
||||
await createCommitStatus(context, github, head_sha, "pending");
|
||||
};
|
||||
|
||||
const updateStatus = async (context, github, sha, needs) => {
|
||||
const failed = Object.values(needs).some(({ result }) => result === "failure");
|
||||
const state = failed ? "failure" : "success";
|
||||
await createCommitStatus(context, github, sha, state);
|
||||
};
|
||||
|
||||
const fetchWorkflowRuns = async ({ context, github, head_sha }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const SLEEP_DURATION_MS = 5000;
|
||||
const MAX_RETRIES = 5;
|
||||
let prevRuns = [];
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
console.log(`Attempt ${i + 1} to fetch workflow runs`);
|
||||
const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
head_sha,
|
||||
status: "action_required",
|
||||
actor: "mlflow-app[bot]",
|
||||
});
|
||||
|
||||
// If the number of runs has not changed since the last attempt,
|
||||
// we can assume that all the workflow runs have been created.
|
||||
if (runs.length > 0 && runs.length === prevRuns.length) {
|
||||
return runs;
|
||||
}
|
||||
|
||||
prevRuns = runs;
|
||||
await new Promise((resolve) => setTimeout(resolve, SLEEP_DURATION_MS));
|
||||
}
|
||||
return prevRuns;
|
||||
};
|
||||
|
||||
const approveWorkflowRuns = async (context, github, head_sha) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowRuns = await fetchWorkflowRuns({ context, github, head_sha });
|
||||
const approvePromises = workflowRuns.map((run) =>
|
||||
github.rest.actions.approveWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
})
|
||||
);
|
||||
const results = await Promise.allSettled(approvePromises);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
console.error(`Failed to approve run: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_AUTHOR_ASSOCIATIONS = ["owner", "member", "collaborator"];
|
||||
|
||||
const isAllowedUser = ({ author_association, user }) => {
|
||||
return (
|
||||
VALID_AUTHOR_ASSOCIATIONS.includes(author_association.toLowerCase()) ||
|
||||
// Allow Copilot and mlflow-app bot to run this workflow
|
||||
(user &&
|
||||
user.type.toLowerCase() === "bot" &&
|
||||
["copilot", "mlflow-app[bot]"].includes(user.login.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
const validatePermissions = async (context, github) => {
|
||||
const { comment } = context.payload;
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
|
||||
// Check if commenter is owner/member/collaborator or an allowed bot
|
||||
if (!isAllowedUser({ author_association: comment.author_association, user: comment.user })) {
|
||||
const message = `This workflow can only be triggered by a repository owner, member, or collaborator. @${comment.user.login} (${comment.author_association}) does not have sufficient permissions.`;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: ${message}`,
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
const prAuthorAssociation = pr.author_association.toLowerCase();
|
||||
|
||||
// If PR author is not a trusted user, this is a community PR
|
||||
if (!isAllowedUser({ author_association: prAuthorAssociation, user: pr.user })) {
|
||||
// Community PR — require at least one approved review
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const hasApproval = reviews.some((review) => review.state === "APPROVED");
|
||||
|
||||
if (!hasApproval) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: This workflow requires an approved review before running on community PRs. Please approve the PR and comment \`/autoformat\` again.`,
|
||||
});
|
||||
throw new Error("This workflow requires an approved review before running on community PRs.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkMaintainerAccess = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
const { runId } = context;
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
|
||||
// Skip maintainer access check for copilot bot PRs
|
||||
// Copilot bot creates PRs that are owned by the repository and don't need the same permission model
|
||||
if (
|
||||
pr.data.user?.type?.toLowerCase() === "bot" &&
|
||||
pr.data.user?.login?.toLowerCase() === "copilot"
|
||||
) {
|
||||
console.log(`Skipping maintainer access check for copilot bot PR #${pull_number}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isForkPR = pr.data.head.repo.full_name !== pr.data.base.repo.full_name;
|
||||
if (isForkPR && !pr.data.maintainer_can_modify) {
|
||||
const workflowRunUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly.
|
||||
|
||||
Please:
|
||||
1. Check the "Allow edits and access to secrets by maintainers" checkbox on this pull request
|
||||
2. Comment \`/autoformat\` again
|
||||
|
||||
This permission is required for the autoformat bot to push changes to your branch.
|
||||
|
||||
**Details:** [View workflow run](${workflowRunUrl})`,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
'The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
shouldAutoformat,
|
||||
getPullInfo,
|
||||
createReaction,
|
||||
createStatus,
|
||||
updateStatus,
|
||||
approveWorkflowRuns,
|
||||
checkMaintainerAccess,
|
||||
validatePermissions,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
# Autoformat
|
||||
|
||||
## Testing
|
||||
|
||||
1. Checkout a new branch and make changes.
|
||||
1. Push the branch to your fork (https://github.com/{your_username}/mlflow).
|
||||
1. Switch the default branch of your fork to the branch you just pushed.
|
||||
1. Create a GitHub token.
|
||||
1. Create a new Actions secret with the name `MLFLOW_AUTOMATION_TOKEN` and put the token value.
|
||||
1. Checkout another new branch and run the following commands to make dummy changes.
|
||||
|
||||
```shell
|
||||
# python
|
||||
echo "" >> setup.py
|
||||
# js
|
||||
echo "" >> mlflow/server/js/src/experiment-tracking/components/App.js
|
||||
# protos
|
||||
echo "message Foo {}" >> mlflow/protos/service.proto
|
||||
```
|
||||
|
||||
1. Create a PR from the branch containing the dummy changes in your fork.
|
||||
1. Comment `/autoformat` on the PR and ensure the workflow runs successfully.
|
||||
The workflow status can be checked at https://github.com/{your_username}/mlflow/actions/workflows/autoformat.yml.
|
||||
1. Delete the GitHub token and reset the default branch.
|
||||
@@ -0,0 +1,324 @@
|
||||
# See .github/workflows/autoformat.md for instructions on how to test this workflow.
|
||||
|
||||
name: Autoformat
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-comment:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/autoformat')
|
||||
permissions:
|
||||
statuses: write # autoformat.createStatus
|
||||
pull-requests: write # autoformat.createReaction on PRs
|
||||
outputs:
|
||||
should_autoformat: ${{ fromJSON(steps.judge.outputs.result).shouldAutoformat }}
|
||||
repository: ${{ fromJSON(steps.judge.outputs.result).repository }}
|
||||
head_ref: ${{ fromJSON(steps.judge.outputs.result).head_ref }}
|
||||
head_sha: ${{ fromJSON(steps.judge.outputs.result).head_sha }}
|
||||
base_ref: ${{ fromJSON(steps.judge.outputs.result).base_ref }}
|
||||
base_sha: ${{ fromJSON(steps.judge.outputs.result).base_sha }}
|
||||
base_repo: ${{ fromJSON(steps.judge.outputs.result).base_repo }}
|
||||
pull_number: ${{ fromJSON(steps.judge.outputs.result).pull_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: judge
|
||||
id: judge
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
core.debug(JSON.stringify(context, null, 2));
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
const { comment } = context.payload;
|
||||
const shouldAutoformat = autoformat.shouldAutoformat(comment);
|
||||
if (shouldAutoformat) {
|
||||
await autoformat.validatePermissions(context, github);
|
||||
await autoformat.createReaction(context, github);
|
||||
await autoformat.createStatus(context, github, core);
|
||||
}
|
||||
const pullInfo = await autoformat.getPullInfo(context, github);
|
||||
return { ...pullInfo, shouldAutoformat };
|
||||
|
||||
- name: Check maintainer access
|
||||
if: fromJSON(steps.judge.outputs.result).shouldAutoformat
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
await autoformat.checkMaintainerAccess(context, github);
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: check-comment
|
||||
if: needs.check-comment.outputs.should_autoformat == 'true'
|
||||
permissions:
|
||||
pull-requests: read # view files modified in PR
|
||||
outputs:
|
||||
reformatted: ${{ steps.patch.outputs.reformatted }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ needs.check-comment.outputs.repository }}
|
||||
ref: ${{ needs.check-comment.outputs.head_ref }}
|
||||
# Set fetch-depth to merge the base branch
|
||||
fetch-depth: 100
|
||||
- name: Verify head SHA
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
run: |
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::HEAD has changed since the /autoformat comment (expected $EXPECTED_SHA, got $actual_sha). Please re-comment /autoformat."
|
||||
exit 1
|
||||
fi
|
||||
- name: Check diff
|
||||
id: diff
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PULL_NUMBER: ${{ needs.check-comment.outputs.pull_number }}
|
||||
run: |
|
||||
changed_files="$(gh pr view --repo $REPO $PULL_NUMBER --json files --jq '.files.[].path')"
|
||||
protos=$([[ -z $(echo "$changed_files" | grep '^\(mlflow/protos\|tests/protos\)') ]] && echo "false" || echo "true")
|
||||
js=$([[ -z $(echo "$changed_files" | grep '^mlflow/server/js') ]] && echo "false" || echo "true")
|
||||
docs=$([[ -z $(echo "$changed_files" | grep '^docs/') ]] && echo "false" || echo "true")
|
||||
r=$([[ -z $(echo "$changed_files" | grep '^mlflow/R/mlflow') ]] && echo "false" || echo "true")
|
||||
db=$([[ -z $(echo "$changed_files" | grep '^mlflow/store/db_migrations/') ]] && echo "false" || echo "true")
|
||||
api=$([[ -z $(echo "$changed_files" | grep -E '(^mlflow/.*\.py$|^docs/api_reference/.*\.rst$)') ]] && echo "false" || echo "true")
|
||||
echo "protos=$protos" >> $GITHUB_OUTPUT
|
||||
echo "js=$js" >> $GITHUB_OUTPUT
|
||||
echo "docs=$docs" >> $GITHUB_OUTPUT
|
||||
echo "r=$r" >> $GITHUB_OUTPUT
|
||||
echo "db=$db" >> $GITHUB_OUTPUT
|
||||
echo "api=$api" >> $GITHUB_OUTPUT
|
||||
# Merge the base branch (which is usually master) to apply formatting using the latest configurations.
|
||||
- name: Merge base branch
|
||||
env:
|
||||
BASE_REPO: ${{ needs.check-comment.outputs.base_repo }}
|
||||
BASE_REF: ${{ needs.check-comment.outputs.base_ref }}
|
||||
run: |
|
||||
# This identity is only used for the temporary merge commit and is not pushed.
|
||||
git config user.name 'name'
|
||||
git config user.email 'email'
|
||||
git remote add base https://github.com/$BASE_REPO.git
|
||||
git fetch base $BASE_REF
|
||||
git merge base/$BASE_REF
|
||||
- uses: ./.github/actions/setup-python
|
||||
# ************************************************************************
|
||||
# pre-commit
|
||||
# ************************************************************************
|
||||
- run: |
|
||||
uv run --only-group lint pre-commit install --install-hooks
|
||||
uv run --only-group lint pre-commit run install-bin -a -v
|
||||
uv run --only-group lint pre-commit run --all-files --color=always || true
|
||||
# ************************************************************************
|
||||
# protos
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.protos == 'true'
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
run: |
|
||||
# Run the script multiple times. The changes generated by the first run
|
||||
# may trigger additional changes, which need to be applied in subsequent runs.
|
||||
for i in {1..3}; do
|
||||
./dev/generate-protos.sh
|
||||
done
|
||||
# ************************************************************************
|
||||
# DB
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.db == 'true'
|
||||
run: |
|
||||
tests/db/update_schemas.sh
|
||||
# ************************************************************************
|
||||
# js
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
uses: ./.github/actions/setup-node
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn install --immutable
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn lint:fix
|
||||
yarn prettier:fix
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn i18n
|
||||
- if: steps.diff.outputs.docs == 'true'
|
||||
working-directory: docs
|
||||
run: |
|
||||
npm ci
|
||||
- if: steps.diff.outputs.docs == 'true'
|
||||
working-directory: docs
|
||||
run: |
|
||||
npm run prettier:fix
|
||||
# ************************************************************************
|
||||
# R
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.r == 'true'
|
||||
working-directory: docs/api_reference
|
||||
run: |
|
||||
./build-rdoc.sh
|
||||
# ************************************************************************
|
||||
# API Reference
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.api == 'true'
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
uv run --directory docs/api_reference make dummy
|
||||
# ************************************************************************
|
||||
# Upload patch
|
||||
# ************************************************************************
|
||||
- name: Create patch
|
||||
id: patch
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
git add -N .
|
||||
git diff > $RUN_ID.diff
|
||||
reformatted=$([[ -s $RUN_ID.diff ]] && echo "true" || echo "false")
|
||||
echo "reformatted=$reformatted" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload patch
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ github.run_id }}.diff
|
||||
path: ${{ github.run_id }}.diff
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
push:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
needs: [check-comment, format]
|
||||
if: needs.format.outputs.reformatted == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
head_sha: ${{ steps.push.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
# See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps
|
||||
# for how to rotate the private key
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
repository: ${{ needs.check-comment.outputs.repository }}
|
||||
ref: ${{ needs.check-comment.outputs.head_ref }}
|
||||
# Set fetch-depth to merge the base branch
|
||||
fetch-depth: 100
|
||||
# As reported in https://github.com/orgs/community/discussions/25702, if an action pushes
|
||||
# code using `GITHUB_TOKEN`, that won't trigger new workflow runs on the PR.
|
||||
# A personal access token is required to trigger new workflow runs.
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Verify head SHA
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
run: |
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::HEAD has changed since the /autoformat comment (expected $EXPECTED_SHA, got $actual_sha). Please re-comment /autoformat."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure git
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-comment.outputs.pull_number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author')
|
||||
IS_BOT=$(echo "$AUTHOR" | jq -r '.is_bot')
|
||||
if [ "$IS_BOT" = "false" ]; then
|
||||
LOGIN=$(echo "$AUTHOR" | jq -r '.login')
|
||||
else
|
||||
LOGIN="mlflow-app[bot]"
|
||||
fi
|
||||
ID=$(gh api "users/$LOGIN" --jq '.id')
|
||||
git config user.name "$LOGIN"
|
||||
git config user.email "${ID}+${LOGIN}@users.noreply.github.com"
|
||||
|
||||
- name: Merge base branch
|
||||
env:
|
||||
BASE_REPO: ${{ needs.check-comment.outputs.base_repo }}
|
||||
BASE_REF: ${{ needs.check-comment.outputs.base_ref }}
|
||||
run: |
|
||||
git remote add base https://github.com/${BASE_REPO}.git
|
||||
git fetch base $BASE_REF
|
||||
git merge base/${BASE_REF}
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ github.run_id }}.diff
|
||||
path: /tmp
|
||||
|
||||
- name: Apply patch and push
|
||||
id: push
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
git apply /tmp/${RUN_ID}.diff
|
||||
git add .
|
||||
git commit -sm "Autoformat: https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
git push
|
||||
|
||||
update-status:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
needs: [check-comment, format, push]
|
||||
if: always() && needs.check-comment.outputs.should_autoformat == 'true'
|
||||
permissions:
|
||||
statuses: write # To update check statuses
|
||||
actions: write # To approve workflow runs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Update status
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
NEEDS_JSON: ${{ toJson(needs) }}
|
||||
HEAD_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
PUSH_HEAD_SHA: ${{ needs.push.outputs.head_sha }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const needs = JSON.parse(process.env.NEEDS_JSON);
|
||||
const head_sha = process.env.HEAD_SHA;
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
const push_head_sha = process.env.PUSH_HEAD_SHA;
|
||||
if (push_head_sha) {
|
||||
await autoformat.approveWorkflowRuns(context, github, push_head_sha);
|
||||
}
|
||||
await autoformat.updateStatus(context, github, head_sha, needs);
|
||||
@@ -0,0 +1,194 @@
|
||||
# Build a wheel for MLflow and upload it as an artifact.
|
||||
name: build-wheel
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "The branch, tag or SHA to build the wheel from."
|
||||
required: true
|
||||
default: "master"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type: ["dev", "skinny", "tracing"]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- if: matrix.type == 'dev'
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build UI
|
||||
if: matrix.type == 'dev'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
|
||||
- name: Create placeholder UI
|
||||
if: matrix.type != 'dev'
|
||||
run: |
|
||||
mkdir -p mlflow/server/js/build
|
||||
echo "<html></html>" > mlflow/server/js/build/index.html
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system build setuptools twine wheel
|
||||
|
||||
- name: Build distribution files
|
||||
id: build-dist
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
MATRIX_TYPE: ${{ matrix.type }}
|
||||
run: |
|
||||
# if workflow_dispatch is triggered, use the specified ref
|
||||
if [ "$EVENT_NAME" == "workflow_dispatch" ]; then
|
||||
SHA_OPT="--sha $(git rev-parse HEAD)"
|
||||
else
|
||||
SHA_OPT=""
|
||||
fi
|
||||
|
||||
python dev/build.py --package-type "$MATRIX_TYPE" $SHA_OPT
|
||||
|
||||
# List distribution files and check their file sizes
|
||||
ls -lh dist
|
||||
|
||||
# Set step outputs
|
||||
sdist_path=$(find dist -type f -name "*.tar.gz")
|
||||
wheel_path=$(find dist -type f -name "*.whl")
|
||||
wheel_name=$(basename $wheel_path)
|
||||
wheel_size=$(stat -c %s $wheel_path)
|
||||
echo "sdist-path=${sdist_path}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-path=${wheel_path}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-name=${wheel_name}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-size=${wheel_size}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: List files in source distribution
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
run: |
|
||||
tar -tf $SDIST_PATH
|
||||
|
||||
- name: List files in binary distribution
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
unzip -l $WHEEL_PATH
|
||||
|
||||
- name: Compare files in source and binary distributions
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
tar -tzf $SDIST_PATH | grep -v '/$' | cut -d'/' -f2- | sort > /tmp/source.txt
|
||||
zipinfo -1 $WHEEL_PATH | sort > /tmp/wheel.txt
|
||||
diff /tmp/source.txt /tmp/wheel.txt || true
|
||||
|
||||
- name: Run twine check
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
twine check --strict $WHEEL_PATH
|
||||
|
||||
- name: Test installation from tarball
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
run: |
|
||||
uv pip install --system $SDIST_PATH
|
||||
python -c "import mlflow; print(mlflow.__version__)"
|
||||
python -c "from mlflow import *"
|
||||
|
||||
- name: Test installation from wheel
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
uv pip install --system --force-reinstall $WHEEL_PATH
|
||||
python -c "import mlflow; print(mlflow.__version__)"
|
||||
python -c "from mlflow import *"
|
||||
|
||||
- name: Test installation from GitHub
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
MATRIX_TYPE: ${{ matrix.type }}
|
||||
run: |
|
||||
if [ "$MATRIX_TYPE" == "skinny" ]; then
|
||||
URL="git+https://github.com/${REPO}.git@${REF}#subdirectory=libs/skinny"
|
||||
elif [ "$MATRIX_TYPE" == "tracing" ]; then
|
||||
URL="git+https://github.com/${REPO}.git@${REF}#subdirectory=libs/tracing"
|
||||
else
|
||||
URL="git+https://github.com/${REPO}.git@${REF}"
|
||||
fi
|
||||
|
||||
uv run --isolated --no-project --with $URL python -I -c 'import mlflow; print(mlflow.__version__)'
|
||||
|
||||
- name: Test dev/install-skinny.sh
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
dev/install-skinny.sh pull/$PR_NUMBER/merge
|
||||
|
||||
# Anyone with read access can download the uploaded wheel on GitHub.
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: upload-wheel
|
||||
with:
|
||||
name: ${{ steps.build-dist.outputs.wheel-name }}
|
||||
path: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Generate summary
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
ARTIFACT_URL: ${{ steps.upload-wheel.outputs.artifact-url }}
|
||||
run: |
|
||||
echo "### Download URL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$ARTIFACT_URL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Notes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- The artifact will be deleted after 7 days." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Unzip the downloaded artifact to get the wheel." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cancel workflow runs associated with a pull request when it is closed or merged.
|
||||
name: Cancel
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- closed
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # to cancel workflow runs
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh run list --repo "$REPO" --commit "$HEAD_SHA" --event pull_request \
|
||||
--json databaseId,status,name \
|
||||
--jq '.[] | select(.status != "completed" and .name != "release-note") | .databaseId' |
|
||||
while read -r run_id; do
|
||||
gh run cancel "$run_id" --repo "$REPO" || true
|
||||
done
|
||||
@@ -0,0 +1,42 @@
|
||||
name: cherry-picks-warn
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
branches:
|
||||
- branch-[0-9]+.[0-9]+
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write # to post a comment on the PR
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr comment "$PR" --repo "$REPO" --body '# ⚠️ Important: Cherry-Pick Merge Instructions
|
||||
|
||||
**If you are cherry-picking commits to a release branch, "Rebase and merge" must be used when merging this PR, NOT "Squash and merge".**
|
||||
|
||||
### Why "Squash and merge" causes problems:
|
||||
|
||||
- It makes reverting individual commits impossible
|
||||
- It removes the association between original and cherry-picked commits
|
||||
- It makes it difficult to track which commits have been cherry-picked
|
||||
- It causes incorrect results in:
|
||||
- [`update-release-labels.yml`](.github/workflows/update-release-labels.yml)
|
||||
- [`update_changelog.py`](dev/update_changelog.py)
|
||||
- [`check_patch_prs.py`](dev/check_patch_prs.py)
|
||||
|
||||
If "Rebase and merge" is disabled, follow [Configuring commit rebasing for pull requests](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/configuring-commit-rebasing-for-pull-requests) to enable it.'
|
||||
@@ -0,0 +1,36 @@
|
||||
name: close-security-issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
close:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
title_lower=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if echo "$title_lower" | grep -qE "security\s+vulnerability"; then
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "$(cat <<'EOF'
|
||||
This issue has been automatically closed because it appears to report a security vulnerability.
|
||||
|
||||
Please report security vulnerabilities through [GitHub's private vulnerability reporting](https://github.com/mlflow/mlflow/security/advisories/new) instead. See our [Security Policy](https://github.com/mlflow/mlflow/blob/master/SECURITY.md) for more details.
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
const { getCloseReason } = require("./auto-close-pr.js");
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const closeReason = await getCloseReason({ github, context });
|
||||
if (closeReason) {
|
||||
console.log("PR will be auto-closed. Skipping labeling.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const number = context.payload.pull_request.number;
|
||||
|
||||
const result = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, repo, number }
|
||||
);
|
||||
|
||||
const issues = result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
for (const { number: issue_number } of issues) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: ["has-closing-pr"],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Closing PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
closing-pr:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: read # closing-pr.js reads the PR body
|
||||
issues: write # closing-pr.js labels issues
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/closing-pr.js`
|
||||
);
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Label Community PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-community:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Skip bot PRs
|
||||
if [[ "$AUTHOR_TYPE" == "Bot" ]]; then
|
||||
echo "Bot PR (type: $AUTHOR_TYPE), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip internal PRs based on author association
|
||||
if [[ "$ASSOCIATION" =~ ^(MEMBER|COLLABORATOR|OWNER)$ ]]; then
|
||||
echo "Internal PR (association: $ASSOCIATION), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check user profile for Databricks affiliation
|
||||
PROFILE=$(gh api "users/$AUTHOR" --jq '[.company // "", .email // ""] | join("\n")')
|
||||
if echo "$PROFILE" | grep -iq 'databricks'; then
|
||||
echo "Internal PR (profile contains 'databricks'), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check commit author emails for @databricks.com
|
||||
if gh api "repos/$REPO/pulls/$PR_NUMBER/commits" \
|
||||
--jq '.[].commit.author.email' | grep -iq '@databricks\.com'; then
|
||||
echo "Internal PR (commit email ends with @databricks.com), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Add community label
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "community"
|
||||
echo "Added 'community' label to PR #$PR_NUMBER"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: copilot-setup-steps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --only-group lint
|
||||
- name: pre-commit setup
|
||||
run: |
|
||||
uv run --only-group lint pre-commit install --install-hooks
|
||||
uv run --only-group lint pre-commit run install-bin -a -v
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Cross version test runner
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
if: >
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/cvt') &&
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) &&
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch cross-version tests
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
flavors=$(printf '%s' "$COMMENT_BODY" | sed -nE '1 s|^/cvt[[:space:]]+(.+)$|\1|p')
|
||||
if [ -z "$flavors" ]; then
|
||||
echo "No flavors specified; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pr_json=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER")
|
||||
base_ref=$(jq -r .base.ref <<< "$pr_json")
|
||||
merge_sha=$(jq -r .merge_commit_sha <<< "$pr_json")
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg ref "$base_ref" \
|
||||
--arg merge_sha "$merge_sha" \
|
||||
--arg flavors "$flavors" \
|
||||
--arg repo_full "$GH_REPO" \
|
||||
'{ref: $ref, return_run_details: true, inputs: {repository: $repo_full, ref: $merge_sha, flavors: $flavors}}')
|
||||
|
||||
run_url=$(printf '%s' "$payload" | gh api -X POST \
|
||||
"repos/$GH_REPO/actions/workflows/cross-version-tests.yml/dispatches" \
|
||||
--input - --jq .html_url)
|
||||
|
||||
gh pr comment "$PR_NUMBER" --body "Cross-version test run started: $run_url"
|
||||
@@ -0,0 +1,196 @@
|
||||
# Cross version testing
|
||||
|
||||
## What is cross version testing?
|
||||
|
||||
Cross version testing is a testing strategy to ensure ML integrations in MLflow such as
|
||||
`mlflow.sklearn` work properly with their associated packages across various versions.
|
||||
|
||||
## Key files
|
||||
|
||||
| File (relative path from the root) | Role |
|
||||
| :---------------------------------------------- | :---------------------------------------------------------------------- |
|
||||
| [`mlflow/ml-package-versions.yml`][] | Define which versions to test for each ML package. |
|
||||
| [`flavors matrix`][flavors-cli] | Generate a test matrix from `ml-package-versions.yml` (`dev/flavors/`). |
|
||||
| [`flavors update`][flavors-cli] | Update `ml-package-versions.yml` when releasing a new version. |
|
||||
| [`.github/workflows/cross-version-tests.yml`][] | Define a Github Actions workflow for cross version testing. |
|
||||
|
||||
[`mlflow/ml-package-versions.yml`]: ../../mlflow/ml-package-versions.yml
|
||||
[flavors-cli]: ../../dev/flavors/
|
||||
[`.github/workflows/cross-version-tests.yml`]: ./cross-version-tests.yml
|
||||
|
||||
## Configuration keys in `ml-package-versions.yml`
|
||||
|
||||
```yml
|
||||
# Note this is just an example and not the actual sklearn configuration.
|
||||
|
||||
# The top-level key specifies the integration name.
|
||||
sklearn:
|
||||
package_info:
|
||||
# [Required] `pip_release` specifies the package this integration depends on.
|
||||
pip_release: "scikit-learn"
|
||||
|
||||
# [Optional] `install_dev` specifies a set of commands to install the dev version of the package.
|
||||
# For example, the command below builds a wheel from the latest main branch of
|
||||
# the scikit-learn repository and installs it.
|
||||
#
|
||||
# The aim of testing the dev version is to spot issues as early as possible before they get
|
||||
# piled up, and fix them incrementally rather than fixing them at once when the package
|
||||
# releases a new version.
|
||||
install_dev: |
|
||||
pip install git+https://github.com/scikit-learn/scikit-learn.git
|
||||
|
||||
# [At least one of `models` and `autologging` must be specified]
|
||||
# `models` specifies the configuration for model serialization and serving tests.
|
||||
# `autologging` specifies the configuration for autologging tests.
|
||||
models or autologging:
|
||||
# [Optional] `requirements` specifies additional pip requirements required for running tests.
|
||||
# For example, '">= 0.24.0": ["xgboost"]' is interpreted as 'if the version of scikit-learn
|
||||
# to install is newer than or equal to 0.24.0, install xgboost'.
|
||||
requirements:
|
||||
">= 0.24.0": ["xgboost"]
|
||||
|
||||
# [Required] `minimum` specifies the minimum supported version for the latest release of MLflow.
|
||||
minimum: "0.20.3"
|
||||
|
||||
# [Required] `maximum` specifies the maximum supported version for the latest release of MLflow.
|
||||
maximum: "1.0"
|
||||
|
||||
# [Optional] `unsupported` specifies a list of versions that should NOT be supported due to
|
||||
# unacceptable issues or bugs.
|
||||
unsupported: ["0.21.3"]
|
||||
|
||||
# [Required] `run` specifies a set of commands to run tests.
|
||||
run: |
|
||||
pytest tests/sklearn/test_sklearn_model_export.py
|
||||
```
|
||||
|
||||
## How do we determine which versions to test?
|
||||
|
||||
We determine which versions to test based on the following rules:
|
||||
|
||||
1. Only test [final][] (e.g. `1.0.0`) and [post][] (`1.0.0.post0`) releases.
|
||||
2. Only test the latest micro version in each minor version.
|
||||
For example, if `1.0.0`, `1.0.1`, and `1.0.2` are available, we only test `1.0.2`.
|
||||
3. The `maximum` version defines the maximum **major** version to test.
|
||||
For example, if the value of `maximum` is `1.0.0`, we test `1.1.0` (if available) but not `2.0.0`.
|
||||
4. Always test the `minimum` version.
|
||||
|
||||
[final]: https://www.python.org/dev/peps/pep-0440/#final-releases
|
||||
[post]: https://www.python.org/dev/peps/pep-0440/#post-releases
|
||||
|
||||
The table below describes which `scikit-learn` versions to test for the example configuration in
|
||||
the previous section:
|
||||
|
||||
| Version | Tested | Comment |
|
||||
| :------------ | :----- | -------------------------------------------------- |
|
||||
| 0.20.3 | ✅ | The value of `minimum` |
|
||||
| 0.20.4 | ✅ | The latest micro version of `0.20` |
|
||||
| 0.21rc2 | | |
|
||||
| 0.21.0 | | |
|
||||
| 0.21.1 | | |
|
||||
| 0.21.2 | ✅ | The latest micro version of `0.21` without`0.21.3` |
|
||||
| 0.21.3 | | Excluded by `unsupported` |
|
||||
| 0.22rc2.post1 | | |
|
||||
| 0.22rc3 | | |
|
||||
| 0.22 | | |
|
||||
| 0.22.1 | | |
|
||||
| 0.22.2 | | |
|
||||
| 0.22.2.post1 | ✅ | The latest micro version of `0.22` |
|
||||
| 0.23.0rc1 | | |
|
||||
| 0.23.0 | | |
|
||||
| 0.23.1 | | |
|
||||
| 0.23.2 | ✅ | The latest micro version of `0.23` |
|
||||
| 0.24.dev0 | | |
|
||||
| 0.24.0rc1 | | |
|
||||
| 0.24.0 | | |
|
||||
| 0.24.1 | | |
|
||||
| 0.24.2 | ✅ | The latest micro version of `0.24` |
|
||||
| 1.0rc1 | | |
|
||||
| 1.0rc2 | | |
|
||||
| 1.0 | | The value of `maximum` |
|
||||
| 1.0.1 | ✅ | The latest micro version of `1.0` |
|
||||
| 1.1.dev | ✅ | The version installed by `install_dev` |
|
||||
|
||||
## Why do we run tests against development versions?
|
||||
|
||||
In cross-version testing, we run daily tests against both publicly available and pre-release
|
||||
development versions for all dependent libraries that are used by MLflow.
|
||||
This section explains why.
|
||||
|
||||
### Without dev version test
|
||||
|
||||
First, let's take a look at what would happen **without** dev version test.
|
||||
|
||||
```
|
||||
|
|
||||
├─ XGBoost merges a change on the master branch that breaks MLflow's XGBoost integration.
|
||||
|
|
||||
├─ MLflow 1.20.0 release date
|
||||
|
|
||||
├─ XGBoost 1.5.0 release date
|
||||
├─ ❌ We notice the change here and might need to make a patch release if it's critical.
|
||||
|
|
||||
v
|
||||
time
|
||||
```
|
||||
|
||||
- We didn't notice the change until after XGBoost 1.5.0 was released.
|
||||
- MLflow 1.20.0 doesn't work with XGBoost 1.5.0.
|
||||
|
||||
### With dev version test
|
||||
|
||||
Then, let's take a look at what would happen **with** dev version test.
|
||||
|
||||
```
|
||||
|
|
||||
├─ XGBoost merges a change on the master branch that breaks MLflow's XGBoost integration.
|
||||
├─ ✅ Tests for the XGBoost integration fail -> We can notice the change and apply a fix for it.
|
||||
|
|
||||
├─ MLflow 1.20.0 release date
|
||||
|
|
||||
├─ XGBoost 1.5.0 release date
|
||||
|
|
||||
v
|
||||
time
|
||||
```
|
||||
|
||||
- We can notice the change **before XGBoost 1.5.0 is released** and apply a fix for it **before releasing MLflow 1.20.0**.
|
||||
- MLflow 1.20.0 works with XGBoost 1.5.0.
|
||||
|
||||
## When do we run cross version tests?
|
||||
|
||||
1. Daily at 7:00 UTC using a cron scheduler.
|
||||
[README on the repository root](../../README.md) has a badge ([![badge-img][]][badge-target]) that indicates the status of the most recent cron run.
|
||||
2. When a PR that affects the ML integrations is created. Note we only run tests relevant to
|
||||
the affected ML integrations. For example, a PR that affects files in `mlflow/sklearn` triggers
|
||||
cross version tests for `sklearn`.
|
||||
|
||||
[badge-img]: https://github.com/mlflow/mlflow/workflows/Cross%20version%20tests/badge.svg?event=schedule
|
||||
[badge-target]: https://github.com/mlflow/mlflow/actions?query=workflow%3ACross%2Bversion%2Btests+event%3Aschedule
|
||||
|
||||
## How to run cross version test for dev versions on a pull request
|
||||
|
||||
By default, cross version tests for dev versions are disabled on a pull request.
|
||||
To enable them, the following steps are required.
|
||||
|
||||
1. Click `Labels` in the right sidebar.
|
||||
2. Click the `enable-dev-tests` label and make sure it's applied on the pull request.
|
||||
3. Push a new commit or re-run the `cross-version-tests` workflow.
|
||||
|
||||
See also:
|
||||
|
||||
- [GitHub Docs - Applying a label](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels#applying-a-label)
|
||||
- [GitHub Docs - Re-running workflows and jobs](https://docs.github.com/en/actions/managing-workflow-runs/re-running-workflows-and-jobs)
|
||||
|
||||
## How to run cross version tests manually
|
||||
|
||||
The `cross-version-tests.yml` workflow can be run manually without creating a pull request.
|
||||
|
||||
1. Open https://github.com/mlflow/mlflow/actions/workflows/cross-version-tests.yml.
|
||||
2. Click `Run workflow`.
|
||||
3. Fill in the input parameters.
|
||||
4. Click `Run workflow` at the bottom of the parameter input form.
|
||||
|
||||
See also:
|
||||
|
||||
- [GitHub Docs - Manually running a workflow](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow)
|
||||
@@ -0,0 +1,218 @@
|
||||
name: Cross version tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
flavors:
|
||||
description: "[Optional] Comma-separated string specifying which flavors to test (e.g. 'sklearn, xgboost'). If unspecified, all flavors are tested."
|
||||
required: false
|
||||
default: ""
|
||||
versions:
|
||||
description: "[Optional] Comma-separated string specifying which versions to test (e.g. '1.2.3, 4.5.6'). If unspecified, all versions are tested."
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# Run this workflow daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
set-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix1: ${{ steps.set-matrix.outputs.matrix1 }}
|
||||
matrix2: ${{ steps.set-matrix.outputs.matrix2 }}
|
||||
is_matrix1_empty: ${{ steps.set-matrix.outputs.is_matrix1_empty }}
|
||||
is_matrix2_empty: ${{ steps.set-matrix.outputs.is_matrix2_empty }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
pin-micro-version: false
|
||||
- name: Install flavors
|
||||
run: |
|
||||
uv sync --package flavors
|
||||
- name: Check labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: check-labels
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
if (context.eventName !== "pull_request") {
|
||||
return {
|
||||
enable_dev_tests: true,
|
||||
only_latest: false,
|
||||
};
|
||||
}
|
||||
const labelNames = context.payload.pull_request.labels.map(l => l.name);
|
||||
return {
|
||||
enable_dev_tests: labelNames.includes("enable-dev-tests"),
|
||||
only_latest: labelNames.includes("only-latest"),
|
||||
};
|
||||
- name: Test flavors CLI
|
||||
run: |
|
||||
uv run --no-sync pytest --noconftest dev/flavors/tests
|
||||
- id: set-matrix
|
||||
name: Set matrix
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
ENABLE_DEV_TESTS: ${{ fromJson(steps.check-labels.outputs.result).enable_dev_tests }}
|
||||
ONLY_LATEST: ${{ fromJson(steps.check-labels.outputs.result).only_latest }}
|
||||
FLAVORS: ${{ github.event.inputs.flavors }}
|
||||
VERSIONS: ${{ github.event.inputs.versions }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
REF_VERSIONS_YAML=/tmp/ref-versions.yml
|
||||
git fetch origin "$BASE_REF" --depth=1
|
||||
git show "origin/$BASE_REF:mlflow/ml-package-versions.yml" > "$REF_VERSIONS_YAML"
|
||||
CHANGED_FILES=$(gh pr view "$PR_NUMBER" --json files --jq '.files[].path' | grep -v '^mlflow/server/js' || true)
|
||||
NO_DEV_FLAG=$([ "$ENABLE_DEV_TESTS" == "true" ] && echo "" || echo "--no-dev")
|
||||
ONLY_LATEST_FLAG=$([ "$ONLY_LATEST" == "true" ] && echo "--only-latest" || echo "")
|
||||
uv run --no-sync flavors matrix --ref-versions-yaml "$REF_VERSIONS_YAML" --changed-files "$CHANGED_FILES" $NO_DEV_FLAG $ONLY_LATEST_FLAG
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
uv run --no-sync flavors matrix --flavors "$FLAVORS" --versions "$VERSIONS"
|
||||
else
|
||||
uv run --no-sync flavors matrix
|
||||
fi
|
||||
|
||||
test1:
|
||||
needs: set-matrix
|
||||
if: needs.set-matrix.outputs.is_matrix1_empty == 'false'
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.set-matrix.outputs.matrix1) }}
|
||||
steps: &test-steps
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
if: matrix.free_disk_space
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
with:
|
||||
java-version: ${{ matrix.java }}
|
||||
- name: Remove constraints
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
# Remove any constraints for the current package to prevent installation conflicts
|
||||
sed -i '/^'"$PACKAGE"'/d' requirements/constraints.txt
|
||||
sed -i '/^constraint-dependencies = \[/,/^\]/{ /'"$PACKAGE"'/d }' pyproject.toml
|
||||
|
||||
if ! git diff --exit-code requirements/constraints.txt pyproject.toml; then
|
||||
git diff
|
||||
git config user.name 'mlflow-app[bot]'
|
||||
git config user.email 'mlflow-app[bot]@users.noreply.github.com'
|
||||
git add requirements/constraints.txt pyproject.toml
|
||||
git commit -m "Remove constraints for testing"
|
||||
fi
|
||||
- name: Install mlflow & test dependencies
|
||||
env:
|
||||
MATRIX_CATEGORY: ${{ matrix.category }}
|
||||
run: |
|
||||
# setuptools 82.0.0 removed pkg_resources, this breaking change breaks some packages like transformers
|
||||
uv pip install --system -U wheel "setuptools<82"
|
||||
# For tracing SDK test, install the tracing package from the local path and minimal test dependencies
|
||||
if [[ "$MATRIX_CATEGORY" == "tracing-sdk" ]]; then
|
||||
uv pip install --system libs/tracing
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov
|
||||
# Other two categories of tests (model/autologging)
|
||||
else
|
||||
uv pip install --system .[extras]
|
||||
uv pip install --system -r requirements/test-requirements.txt
|
||||
fi
|
||||
- name: Install ${{ matrix.package }} ${{ matrix.version }}
|
||||
env:
|
||||
MATRIX_INSTALL: ${{ matrix.install }}
|
||||
run: |
|
||||
eval "$MATRIX_INSTALL"
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Pre-test
|
||||
if: matrix.pre_test
|
||||
env:
|
||||
MATRIX_PRE_TEST: ${{ matrix.pre_test }}
|
||||
run: |
|
||||
eval "$MATRIX_PRE_TEST"
|
||||
- name: Run tests
|
||||
env:
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
SPARK_LOCAL_IP: localhost
|
||||
HF_HUB_ENABLE_HF_TRANSFER: 1
|
||||
MATRIX_RUN: ${{ matrix.run }}
|
||||
run: |
|
||||
eval "$MATRIX_RUN"
|
||||
|
||||
test2:
|
||||
needs: set-matrix
|
||||
if: needs.set-matrix.outputs.is_matrix2_empty == 'false'
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.set-matrix.outputs.matrix2) }}
|
||||
steps: *test-steps
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Dev environment setup
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
paths:
|
||||
- "dev/dev-env-setup.sh"
|
||||
- "dev/test-dev-env-setup.sh"
|
||||
- ".github/workflows/dev-setup.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "dev/dev-env-setup.sh"
|
||||
- "dev/test-dev-env-setup.sh"
|
||||
- ".github/workflows/dev-setup.yml"
|
||||
schedule:
|
||||
- cron: "42 7 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
linux-env-setup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'schedule' || github.repository == 'mlflow/dev'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
with:
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Setup environment
|
||||
run: |
|
||||
git config --global user.name "test"
|
||||
git config --global user.email "test@mlflow.org"
|
||||
- name: Run Environment tests
|
||||
run: |
|
||||
TERM=xterm bash ./dev/test-dev-env-setup.sh
|
||||
@@ -0,0 +1,181 @@
|
||||
name: docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- mlflow/**
|
||||
- docs/**
|
||||
- .github/workflows/docs.yml
|
||||
- .github/actions/setup-node/**
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- mlflow/**
|
||||
- docs/**
|
||||
- .github/workflows/docs.yml
|
||||
- .github/actions/setup-node/**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: docs
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- name: Check package-lock.json is up-to-date
|
||||
run: |
|
||||
npm install --package-lock-only --no-audit --no-fund
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "package-lock.json is out of date. Run 'npm install --package-lock-only' locally and commit the result."
|
||||
exit 1
|
||||
}
|
||||
- name: Run lint
|
||||
run: |
|
||||
npm run eslint
|
||||
- name: Run prettier
|
||||
run: |
|
||||
npm run prettier:check
|
||||
|
||||
build:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-java
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
- run: |
|
||||
npm ci
|
||||
- uses: ./.github/actions/show-versions
|
||||
- run: |
|
||||
npm run convert-notebooks
|
||||
- name: Set alias
|
||||
id: alias
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
ALIAS="dev"
|
||||
else
|
||||
ALIAS="pr-$PR_NUMBER"
|
||||
fi
|
||||
echo "value=$ALIAS" >> $GITHUB_OUTPUT
|
||||
- name: Build docs
|
||||
env:
|
||||
GTM_ID: "GTM-TEST"
|
||||
API_REFERENCE_PREFIX: https://${{ steps.alias.outputs.value }}--mlflow-docs-preview.netlify.app/docs/
|
||||
run: |
|
||||
npm run build-all -- --no-r --use-npm
|
||||
- name: Check API inventory
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain api_reference/api_inventory.txt)" ]; then
|
||||
echo "The API inventory file 'docs/api_reference/api_inventory.txt' is outdated (see the diff below)."
|
||||
echo "Please update it by running 'make rsthtml' in the 'docs/api_reference' directory, or post a comment '/autoformat' on this PR if you're a maintainer/collaborator."
|
||||
echo "If the new APIs should be marked as experimental, please decorate them with '@experimental'."
|
||||
echo "Diff:"
|
||||
git diff api_reference/api_inventory.txt
|
||||
exit 1
|
||||
fi
|
||||
- name: Check sitemap
|
||||
run: |
|
||||
npm run sitemap -- https://mlflow.org/docs/latest/sitemap.xml ./build/latest/sitemap.xml
|
||||
- name: Move build artifacts
|
||||
run: |
|
||||
mkdir -p /tmp/docs-build/docs
|
||||
mv build/latest /tmp/docs-build/docs/latest
|
||||
|
||||
# Create `docs/versions.json` for the version selector in the API reference
|
||||
VERSION="$(uv version | cut -d' ' -f2)"
|
||||
echo "{\"versions\": [\"$VERSION\"]}" > /tmp/docs-build/docs/versions.json
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docs-build-${{ github.run_id }}
|
||||
path: /tmp/docs-build
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
test-examples:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs/api_reference
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
- name: Extract examples
|
||||
run: |
|
||||
uv run source/testcode_block.py
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv run pytest .examples
|
||||
|
||||
r:
|
||||
if: (github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build docs
|
||||
working-directory: docs/api_reference
|
||||
run: |
|
||||
./build-rdoc.sh
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "The following files have changed:"
|
||||
git status --porcelain
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,177 @@
|
||||
// Label duplicate community PRs that reference the same issue
|
||||
// Only considers PRs opened in the last 14 days
|
||||
// Keeps the oldest PR and labels newer ones as duplicates
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const DAYS_TO_CONSIDER = 14;
|
||||
const DUPLICATE_LABEL = "duplicate";
|
||||
|
||||
const duplicateMessage = (author, issueNumber, keeperPR) =>
|
||||
`@${author} This PR appears to reference the same issue (#${issueNumber}) as #${keeperPR} (opened earlier). Closing as a duplicate.`;
|
||||
|
||||
// GraphQL query to fetch open PRs created in the last 14 days
|
||||
const QUERY = `
|
||||
query($cursor: String, $searchQuery: String!) {
|
||||
rateLimit { remaining resetAt }
|
||||
search(query: $searchQuery, type: ISSUE, first: 50, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
... on PullRequest {
|
||||
number
|
||||
createdAt
|
||||
url
|
||||
author { login }
|
||||
authorAssociation
|
||||
labels(first: 20) { nodes { name } }
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const shouldProcessPR = (pr) => {
|
||||
// Only process community PRs (skip maintainer PRs)
|
||||
const memberAssociations = ["MEMBER", "OWNER", "COLLABORATOR"];
|
||||
if (memberAssociations.includes(pr.authorAssociation)) {
|
||||
return false;
|
||||
}
|
||||
// Skip PRs already labeled as duplicate
|
||||
const labels = pr.labels?.nodes?.map((l) => l.name) ?? [];
|
||||
if (labels.includes(DUPLICATE_LABEL)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getIssueReferences = (pr) => {
|
||||
const references = pr.closingIssuesReferences?.nodes || [];
|
||||
return references.map((node) => node.number);
|
||||
};
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Calculate the date 14 days ago
|
||||
const fourteenDaysAgo = new Date(Date.now() - DAYS_TO_CONSIDER * MS_PER_DAY);
|
||||
const dateString = fourteenDaysAgo.toISOString().slice(0, 10);
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:open created:>${dateString}`;
|
||||
|
||||
console.log(`Searching for PRs: ${searchQuery}`);
|
||||
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
const allPRs = [];
|
||||
|
||||
// Fetch all open PRs from the last 14 days
|
||||
while (hasNextPage) {
|
||||
const response = await github.graphql(QUERY, { cursor, searchQuery });
|
||||
const { remaining, resetAt } = response.rateLimit;
|
||||
console.log(`Rate limit: ${remaining} remaining, resets at ${resetAt}`);
|
||||
|
||||
const { nodes, pageInfo } = response.search;
|
||||
hasNextPage = pageInfo.hasNextPage;
|
||||
cursor = pageInfo.endCursor;
|
||||
|
||||
allPRs.push(...nodes);
|
||||
}
|
||||
|
||||
console.log(`Found ${allPRs.length} open PRs from the last ${DAYS_TO_CONSIDER} days`);
|
||||
|
||||
// Filter to community PRs only
|
||||
const communityPRs = allPRs.filter(shouldProcessPR);
|
||||
console.log(`${communityPRs.length} are community PRs`);
|
||||
|
||||
// Group PRs by the single issue they reference
|
||||
// Skip PRs that reference multiple issues (ambiguous intent)
|
||||
const prsByIssue = new Map();
|
||||
|
||||
for (const pr of communityPRs) {
|
||||
const issueRefs = getIssueReferences(pr);
|
||||
|
||||
if (issueRefs.length === 0) {
|
||||
// PR doesn't reference any issue, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issueRefs.length > 1) {
|
||||
// PR references multiple issues, skip it (ambiguous)
|
||||
console.log(
|
||||
`Skipping PR #${pr.number}: references multiple issues (${issueRefs.join(", ")})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PR references exactly one issue
|
||||
const issueNumber = issueRefs[0];
|
||||
if (!prsByIssue.has(issueNumber)) {
|
||||
prsByIssue.set(issueNumber, []);
|
||||
}
|
||||
prsByIssue.get(issueNumber).push(pr);
|
||||
}
|
||||
|
||||
console.log(`Found ${prsByIssue.size} issues with associated PRs`);
|
||||
|
||||
// Process each issue that has multiple PRs
|
||||
let closedCount = 0;
|
||||
for (const [issueNumber, prs] of prsByIssue.entries()) {
|
||||
if (prs.length <= 1) {
|
||||
// Only one PR for this issue, no duplicates
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Issue #${issueNumber} has ${prs.length} PRs`);
|
||||
|
||||
// Sort PRs by creation date (oldest first)
|
||||
prs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
|
||||
// Keep the oldest PR, label the rest as duplicates
|
||||
const [keeper, ...duplicates] = prs;
|
||||
console.log(` Keeping PR #${keeper.number} (oldest, created ${keeper.createdAt})`);
|
||||
|
||||
for (const pr of duplicates) {
|
||||
console.log(` Closing PR #${pr.number} as duplicate (created ${pr.createdAt})`);
|
||||
|
||||
// Close first so a failure here leaves the PR open and unlabeled,
|
||||
// letting the next run retry. If we labeled first and then failed
|
||||
// to close, shouldProcessPR would skip the PR forever.
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [DUPLICATE_LABEL],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: duplicateMessage(pr.author.login, issueNumber, keeper.number),
|
||||
});
|
||||
|
||||
closedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Closed ${closedCount} duplicate PRs.`);
|
||||
} catch (error) {
|
||||
if (error.status === 429 || error.message?.includes("rate limit")) {
|
||||
console.log(`Rate limit hit. Exiting gracefully.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Duplicate PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
duplicate-prs:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(".github/workflows/duplicate-prs.js");
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Examples
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
schedule:
|
||||
# Run this action daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
examples:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2]
|
||||
include:
|
||||
- splits: 2
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- name: Check diff
|
||||
id: check-diff
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
FORCE_RUN_EXAMPLES: ${{ contains(github.event.pull_request.labels.*.name, 'examples.yml') }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh pr view "$PR_NUMBER" --json files --jq '.files[].path' | grep "tests/examples\|examples" || true);
|
||||
if [ "$FORCE_RUN_EXAMPLES" = "true" ]; then
|
||||
EXAMPLES_CHANGED="true"
|
||||
else
|
||||
EXAMPLES_CHANGED=$([ ! -z "$CHANGED_FILES" ] && echo "true" || echo "false")
|
||||
fi
|
||||
|
||||
echo -e "CHANGED_FILES:\n$CHANGED_FILES"
|
||||
echo "EXAMPLES_CHANGED: $EXAMPLES_CHANGED"
|
||||
echo "examples_changed=$EXAMPLES_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
|
||||
- name: Install dependencies
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.check-diff.outputs.examples_changed == 'true'
|
||||
run: |
|
||||
source ./dev/install-common-deps.sh --ml
|
||||
uv pip install --system fastapi uvicorn
|
||||
# Required for the transformers example that uses the Whisper model
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Run example tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.check-diff.outputs.examples_changed == 'true'
|
||||
env:
|
||||
SPARK_LOCAL_IP: localhost
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
pytest --splits=$SPLITS --group=$GROUP --serve-wheel tests/examples --durations=30
|
||||
|
||||
- name: Remove conda environments
|
||||
run: |
|
||||
./dev/remove-conda-envs.sh
|
||||
|
||||
- name: Show disk usage
|
||||
run: |
|
||||
df -h
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Show disk usage
|
||||
run: |
|
||||
df -h
|
||||
@@ -0,0 +1,88 @@
|
||||
name: fs2db
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mlflow-version:
|
||||
- "2.22.4" # Latest 2.x release
|
||||
- "3.6.0" # First version after FileStore deprecation
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Install MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: uv run --with "mlflow==$MLFLOW_VERSION" --no-project mlflow --version
|
||||
|
||||
- name: Generate synthetic data for MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: |
|
||||
uv run --with "mlflow==$MLFLOW_VERSION" --no-project python -I \
|
||||
fs2db/src/generate_synthetic_data.py --output /tmp/fs2db/$MLFLOW_VERSION/ --size full
|
||||
|
||||
- name: Run migration for MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: |
|
||||
uv run mlflow migrate-filestore \
|
||||
--source /tmp/fs2db/$MLFLOW_VERSION/ \
|
||||
--target sqlite:////tmp/fs2db/$MLFLOW_VERSION/migrated.db \
|
||||
--no-progress
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Run fs2db pytest tests
|
||||
run: uv run pytest tests/store/fs2db
|
||||
@@ -0,0 +1,112 @@
|
||||
# Daily benchmark for the MLflow AI Gateway to catch performance regressions.
|
||||
# Runs against both sqlite (1 instance) and postgres (4 instances + nginx) backends.
|
||||
#
|
||||
# THRESHOLD CALIBRATION: The default threshold values below are conservative
|
||||
# starting points. Run this workflow a few times and tighten thresholds to
|
||||
# ~2x the observed average. Override per-run via workflow_dispatch inputs.
|
||||
name: MLflow Gateway Benchmark
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 06:00 UTC (off-peak; slow-tests runs at 13:00)
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
requests:
|
||||
description: "Requests per run"
|
||||
required: false
|
||||
default: "200"
|
||||
max_concurrent:
|
||||
description: "Max concurrent requests (blank = use per-backend default: 10 for both)"
|
||||
required: false
|
||||
default: ""
|
||||
max_p50_ms:
|
||||
description: "Max P50 latency ms (blank = use per-backend default)"
|
||||
required: false
|
||||
default: ""
|
||||
max_p99_ms:
|
||||
description: "Max P99 latency ms (blank = use per-backend default)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: (github.event_name == 'schedule' && github.repository == 'mlflow/mlflow') || github.event_name == 'workflow_dispatch'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
backend: [sqlite, postgres]
|
||||
include:
|
||||
- backend: sqlite
|
||||
instances: 1
|
||||
default_max_concurrent: 10
|
||||
default_max_p50_ms: 300
|
||||
default_max_p99_ms: 800
|
||||
- backend: postgres
|
||||
instances: 4
|
||||
default_max_concurrent: 10
|
||||
default_max_p50_ms: 400
|
||||
default_max_p99_ms: 1000
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
env:
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
run: |
|
||||
uv sync --extra gateway
|
||||
if [[ "$BACKEND" == "postgres" ]]; then
|
||||
uv pip install "psycopg2-binary>=2.9,<3"
|
||||
fi
|
||||
- name: Run benchmark (${{ matrix.backend }})
|
||||
env:
|
||||
MLFLOW_ENABLE_INCREMENTAL_SPAN_EXPORT: "true"
|
||||
MLFLOW_USE_BATCH_SPAN_PROCESSOR: "true"
|
||||
MLFLOW_ENABLE_ASYNC_TRACE_LOGGING: "true"
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
INSTANCES: ${{ matrix.instances }}
|
||||
DEFAULT_MAX_CONCURRENT: ${{ matrix.default_max_concurrent }}
|
||||
DEFAULT_MAX_P50_MS: ${{ matrix.default_max_p50_ms }}
|
||||
DEFAULT_MAX_P99_MS: ${{ matrix.default_max_p99_ms }}
|
||||
REQUESTS: ${{ github.event_name == 'workflow_dispatch' && inputs.requests || '200' }}
|
||||
MAX_CONCURRENT_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_concurrent || '' }}
|
||||
MAX_P50_MS_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_p50_ms || '' }}
|
||||
MAX_P99_MS_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_p99_ms || '' }}
|
||||
run: |
|
||||
MAX_CONCURRENT="${MAX_CONCURRENT_OVERRIDE:-$DEFAULT_MAX_CONCURRENT}"
|
||||
MAX_P50_MS="${MAX_P50_MS_OVERRIDE:-$DEFAULT_MAX_P50_MS}"
|
||||
MAX_P99_MS="${MAX_P99_MS_OVERRIDE:-$DEFAULT_MAX_P99_MS}"
|
||||
ARGS=(
|
||||
--instances "$INSTANCES"
|
||||
--database "$BACKEND"
|
||||
--requests "$REQUESTS"
|
||||
--max-concurrent "$MAX_CONCURRENT"
|
||||
--max-p50-ms "$MAX_P50_MS"
|
||||
--max-p99-ms "$MAX_P99_MS"
|
||||
--runs 3
|
||||
)
|
||||
uv run --no-sync dev/benchmarks/gateway/run.py "${ARGS[@]}" \
|
||||
--output "benchmark-results-$BACKEND.json"
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-${{ matrix.backend }}-${{ github.run_id }}
|
||||
path: benchmark-results-${{ matrix.backend }}.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Heads-Up
|
||||
|
||||
# Flag external PRs that touch files coding agents auto-load as instructions
|
||||
# (AGENTS.md, CLAUDE.md, .claude/, .agents/). Running an agent against such a
|
||||
# checkout silently obeys these files, so a malicious one can steer it without
|
||||
# approval. The 'heads-up' label warns reviewers to read them first.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
# Watched paths. Add new entries here to widen coverage.
|
||||
paths:
|
||||
- "AGENTS.md"
|
||||
- "**/AGENTS.md"
|
||||
- "CLAUDE.md"
|
||||
- "**/CLAUDE.md"
|
||||
- ".agents/**"
|
||||
- ".claude/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-heads-up:
|
||||
# Only flag external contributors. Internal edits to these files are routine
|
||||
# and don't need a review signal.
|
||||
if: >
|
||||
github.repository == 'mlflow/mlflow'
|
||||
&& github.event.pull_request.user.type != 'Bot'
|
||||
&& !contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "heads-up"
|
||||
@@ -0,0 +1,113 @@
|
||||
name: Helm Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, "branch-[0-9]+.[0-9]+"]
|
||||
paths:
|
||||
- charts/**
|
||||
- docker/Dockerfile
|
||||
- mlflow/server/**
|
||||
- mlflow/tracking/**
|
||||
- mlflow/cli/**
|
||||
pull_request:
|
||||
paths:
|
||||
- charts/**
|
||||
- docker/Dockerfile
|
||||
- mlflow/server/**
|
||||
- mlflow/tracking/**
|
||||
- mlflow/cli/**
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
|
||||
- name: Lint
|
||||
run: helm lint charts/ --set mlflow.backendStoreUri="sqlite:////tmp/mlflow.db"
|
||||
|
||||
- name: Render templates
|
||||
run: helm template mlflow charts/ --set mlflow.backendStoreUri="sqlite:////tmp/mlflow.db"
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
tls: [false, true]
|
||||
name: e2e (tls=${{ matrix.tls }})
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create Kind cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
|
||||
- name: Get MLflow version
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(grep '^appVersion' charts/Chart.yaml | awk '{print $2}' | tr -d '"')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Docker image
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=$MLFLOW_VERSION \
|
||||
-t mlflow:ci \
|
||||
docker/
|
||||
|
||||
- name: Load image into Kind
|
||||
run: kind load docker-image mlflow:ci --name chart-testing
|
||||
|
||||
- name: Generate TLS certificate
|
||||
if: matrix.tls == true
|
||||
run: |
|
||||
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt -days 1 -nodes \
|
||||
-subj "/CN=localhost"
|
||||
kubectl create secret tls mlflow-tls --cert=tls.crt --key=tls.key
|
||||
|
||||
- name: Deploy chart
|
||||
env:
|
||||
TLS_FLAGS: ${{ matrix.tls == true && '--set tls.enabled=true --set tls.secretName=mlflow-tls' || '' }}
|
||||
run: |
|
||||
helm install mlflow charts/ \
|
||||
--set image.repository=mlflow \
|
||||
--set image.tag=ci \
|
||||
--set image.pullPolicy=Never \
|
||||
--set fullnameOverride=mlflow \
|
||||
--set mlflow.backendStoreUri=sqlite:////tmp/mlflow.db \
|
||||
--set mlflow.artifactsDestination=/tmp/mlartifacts \
|
||||
$TLS_FLAGS \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
|
||||
- name: Verify MLflow is reachable
|
||||
env:
|
||||
SCHEME: ${{ matrix.tls == true && 'https' || 'http' }}
|
||||
CURL_FLAGS: ${{ matrix.tls == true && '--insecure' || '' }}
|
||||
run: |
|
||||
kubectl port-forward svc/mlflow 5000:5000 &
|
||||
curl --retry 10 --retry-connrefused --retry-delay 3 --fail $CURL_FLAGS ${SCHEME}://localhost:5000/health
|
||||
|
||||
- name: Print container logs
|
||||
if: always()
|
||||
run: kubectl logs -l app.kubernetes.io/name=mlflow --tail=-1
|
||||
@@ -0,0 +1,46 @@
|
||||
name: issue-warning
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prepend-warning:
|
||||
if: >-
|
||||
github.repository == 'mlflow/mlflow' &&
|
||||
github.event.issue.created_at >= '2026-03-10T00:00:00Z'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if echo "$ISSUE_BODY" | grep -qF '<!-- issue-warning -->'; then
|
||||
echo "Warning already present, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
body="<!-- issue-warning -->
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the \`ready\` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
|
||||
${ISSUE_BODY}"
|
||||
|
||||
# Why not a comment, but editing the issue body? Coding agents (e.g. "Fix <issue URL>") may not see the comments.
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --body "$body"
|
||||
@@ -0,0 +1,105 @@
|
||||
name: JS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- mlflow/server/js/**
|
||||
- .github/actions/check-component-ids/**
|
||||
- .github/workflows/js.yml
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- mlflow/server/js/**
|
||||
- .github/actions/check-component-ids/**
|
||||
- .github/workflows/js.yml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-component-ids:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check componentId registry
|
||||
uses: ./.github/actions/check-component-ids
|
||||
|
||||
js:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
option: [--testPathPattern, --testPathIgnorePatterns]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mlflow/server/js
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn install --immutable
|
||||
- name: Run lint
|
||||
run: |
|
||||
yarn lint
|
||||
- name: Run prettier
|
||||
run: |
|
||||
yarn prettier:check
|
||||
# TODO: Disabled for now. Revisit after DAIS.
|
||||
# - name: Run knip
|
||||
# run: |
|
||||
# yarn knip
|
||||
- name: Run extract-i18n lint
|
||||
run: |
|
||||
yarn i18n:check
|
||||
- name: Run type-check
|
||||
run: |
|
||||
yarn type-check
|
||||
- name: Run tests
|
||||
env:
|
||||
MATRIX_OPTION: ${{ matrix.option }}
|
||||
# --maxWorkers=2: Jest's default (3) leaks module cache across test
|
||||
# files and peaks ~15 GB RSS on the 16 GB runner; 2 keeps peak ~13 GB.
|
||||
run: |
|
||||
"$GITHUB_WORKSPACE/dev/profile.sh" \
|
||||
yarn test --silent --maxWorkers=2 $MATRIX_OPTION src/experiment-tracking/components
|
||||
- name: Upload profile metrics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: profile-metrics-${{ strategy.job-index }}
|
||||
path: /tmp/profile-metrics.csv
|
||||
# TODO: Uncomment once `gh run download` supports non-zipped artifacts.
|
||||
# https://github.com/cli/cli/issues/13012
|
||||
# archive: false
|
||||
retention-days: 3
|
||||
if-no-files-found: ignore
|
||||
- name: Run build
|
||||
run: |
|
||||
yarn build
|
||||
@@ -0,0 +1,81 @@
|
||||
module.exports = async ({ github, context }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { body, number: issue_number } = context.payload.issue || context.payload.pull_request;
|
||||
const pattern = /- \[(.*?)\]\s*`(.+?)`/g;
|
||||
// Labels extracted from the issue/PR body
|
||||
const bodyLabels = [];
|
||||
let match;
|
||||
while ((match = pattern.exec(body)) !== null) {
|
||||
bodyLabels.push({ checked: match[1].trim().toLowerCase() === "x", name: match[2].trim() });
|
||||
}
|
||||
console.log("Body labels:", bodyLabels);
|
||||
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
// Labels added or removed by a user
|
||||
const userLabels = events
|
||||
.filter(({ event, actor }) => ["labeled", "unlabeled"].includes(event) && actor.type === "User")
|
||||
.map(({ label }) => label.name);
|
||||
console.log("User labels:", userLabels);
|
||||
|
||||
// Labels available in the repository
|
||||
const repoLabels = (
|
||||
await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
).map(({ name }) => name);
|
||||
|
||||
// Exclude labels that are not available in the repository or have been added/removed by a user
|
||||
const labels = bodyLabels.filter(
|
||||
({ name }) => repoLabels.includes(name) && !userLabels.includes(name)
|
||||
);
|
||||
console.log("Labels to add/remove:", labels);
|
||||
|
||||
const existingLabels = (
|
||||
await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
})
|
||||
).map(({ name }) => name);
|
||||
console.log("Existing labels:", existingLabels);
|
||||
|
||||
const labelsToAdd = labels
|
||||
.filter(({ name, checked }) => checked && !existingLabels.includes(name))
|
||||
.map(({ name }) => name);
|
||||
console.log("Labels to add:", labelsToAdd);
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
}
|
||||
|
||||
const labelsToRemove = labels
|
||||
.filter(({ name, checked }) => !checked && existingLabels.includes(name))
|
||||
.map(({ name }) => name);
|
||||
console.log("Labels to remove:", labelsToRemove);
|
||||
if (labelsToRemove.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
labelsToRemove.map((name) =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name,
|
||||
})
|
||||
)
|
||||
);
|
||||
for (const { status, reason } of results) {
|
||||
if (status === "rejected") {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Labeling
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
labeling:
|
||||
if: (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/labeling.js');
|
||||
await script({ github, context });
|
||||
@@ -0,0 +1,41 @@
|
||||
name: External Link Checker
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Runs at 00:00 UTC every day
|
||||
- cron: "0 13 * * *"
|
||||
workflow_dispatch: # Allow manual runs
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/link-checker.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: docs
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-external-links:
|
||||
# Only run on the main repository
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- name: Run external links checker
|
||||
run: |
|
||||
npm run check-links
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
run-macos:
|
||||
- ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
run-macos: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'lint' || 'lint-macos' }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# To ensure `git diff` works correctly in dev/check_function_signatures.py,
|
||||
# we need to fetch enough history to include the base branch.
|
||||
fetch-depth: 300
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
pin-micro-version: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Add problem matchers
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
for f in .github/workflows/matchers/*.json; do
|
||||
echo "::add-matcher::$f"
|
||||
done
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --locked --only-group lint --only-group pytest
|
||||
- name: Cache action pins
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: .cache/action-pins.json
|
||||
key: action-pins-${{ hashFiles('dev/check_actions.py') }}
|
||||
- name: Cache mypy
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: mypy-${{ matrix.os }}-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||
restore-keys: mypy-${{ matrix.os }}-
|
||||
- name: Cache install-bin tools
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
bin/*
|
||||
!bin/install.py
|
||||
!bin/README.md
|
||||
!bin/.gitignore
|
||||
key: install-bin-${{ matrix.os }}-${{ hashFiles('bin/install.py') }}
|
||||
restore-keys: install-bin-${{ matrix.os }}-
|
||||
- name: Cache pre-commit hooks
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ matrix.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: pre-commit-${{ matrix.os }}-
|
||||
- name: Install pre-commit hooks
|
||||
run: |
|
||||
uv run --no-sync pre-commit install --install-hooks
|
||||
uv run --no-sync pre-commit run install-bin -a -v
|
||||
- name: Run pre-commit
|
||||
id: pre-commit
|
||||
env:
|
||||
IS_MAINTAINER: ${{ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association )}}
|
||||
NO_FIX: "true"
|
||||
run: |
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
|
||||
- name: Test clint
|
||||
run: |
|
||||
uv run --no-sync pytest dev/clint
|
||||
|
||||
- name: Test pypi
|
||||
run: |
|
||||
uv run --no-sync pytest dev/pypi
|
||||
|
||||
- name: Check function signatures
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
uv run --no-project dev/check_function_signatures.py
|
||||
|
||||
- name: Check whitespace-only changes
|
||||
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
uv run --no-project dev/check_whitespace_only.py \
|
||||
--repo $REPO \
|
||||
--pr $PR_NUMBER
|
||||
|
||||
- name: Check uv.lock changes
|
||||
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
files=$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json files --jq '.files[].path')
|
||||
if echo "$files" | grep -q '^uv.lock$' && echo "$files" | grep -q '^pyproject.toml$'; then
|
||||
echo '::warning file=pyproject.toml,line=1,col=1::[Non-blocking]' \
|
||||
'Run `uv lock --upgrade-package <package>` if this PR should update package versions.' \
|
||||
'`uv lock` alone won'"'"'t bump them.'
|
||||
fi
|
||||
|
||||
- name: Check unused media
|
||||
run: |
|
||||
dev/find-unused-media.sh
|
||||
@@ -0,0 +1,467 @@
|
||||
name: MLflow tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Use `bash` by default for all `run` steps in this workflow:
|
||||
# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#defaultsrun
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
# Note miniconda is pre-installed in the virtual environments for GitHub Actions:
|
||||
# https://github.com/actions/virtual-environments/blob/main/images/linux/scripts/installers/miniconda.sh
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
SPARK_LOCAL_IP: localhost
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
PYTHONUTF8: "1"
|
||||
_MLFLOW_TESTING_TELEMETRY: "true"
|
||||
MLFLOW_SERVER_ENABLE_JOB_EXECUTION: "false"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
# python-skinny tests cover a subset of mlflow functionality
|
||||
# that is meant to be supported with a smaller dependency footprint.
|
||||
# The python skinny tests cover the subset of mlflow functionality
|
||||
# while also verifying certain dependencies are omitted.
|
||||
python-skinny:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
source ./dev/install-common-deps.sh --skinny
|
||||
- name: Run tests
|
||||
run: |
|
||||
./dev/run-python-skinny-tests.sh
|
||||
|
||||
python:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2, 3, 4]
|
||||
include:
|
||||
- splits: 4
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra extras --extra gateway --extra mcp --extra genai
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
-r requirements/extra-ml-requirements.txt
|
||||
- uses: ./.github/actions/show-versions
|
||||
- uses: ./.github/actions/cache-hf
|
||||
- name: Import check
|
||||
run: |
|
||||
# `-I` is used to avoid importing modules from user-specific site-packages
|
||||
# that might conflict with the built-in modules (e.g. `types`).
|
||||
uv run --no-sync python -I tests/check_mlflow_lazily_imports_ml_packages.py
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
source dev/setup-ssh.sh
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP \
|
||||
--quiet --requires-ssh --ignore-flavors \
|
||||
--ignore=tests/examples \
|
||||
--ignore=tests/evaluate \
|
||||
--ignore=tests/genai \
|
||||
--ignore=tests/docker \
|
||||
tests
|
||||
|
||||
- name: Run databricks-connect related tests
|
||||
run: |
|
||||
# this needs to be run in a separate job because installing databricks-connect could break other
|
||||
# tests that uses normal SparkSession instead of remote SparkSession
|
||||
uv run --no-sync --with 'databricks-agents,databricks-connect' \
|
||||
pytest tests/utils/test_requirements_utils.py::test_infer_pip_requirements_on_databricks_agents
|
||||
|
||||
database:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- name: Build
|
||||
run: |
|
||||
./tests/db/compose.sh pull -q postgresql mysql mssql
|
||||
docker images --digests
|
||||
./tests/db/compose.sh build --build-arg DEPENDENCIES="$(python dev/extract_deps.py)"
|
||||
- name: Run tests
|
||||
run: |
|
||||
set +e
|
||||
err=0
|
||||
trap 'err=1' ERR
|
||||
RESULTS=""
|
||||
for service in $(./tests/db/compose.sh config --services | grep '^mlflow-' | sort)
|
||||
do
|
||||
# Set `--no-TTY` to show container logs on GitHub Actions:
|
||||
# https://github.com/actions/virtual-environments/issues/5022
|
||||
./tests/db/compose.sh run --rm --no-TTY $service pytest \
|
||||
tests/store/tracking/sqlalchemy_store \
|
||||
tests/store/tracking/test_gateway_sql_store.py \
|
||||
tests/store/model_registry/test_sqlalchemy_store.py \
|
||||
tests/store/model_registry/test_sqlalchemy_workspace_store.py \
|
||||
tests/store/workspace/test_sqlalchemy_store.py \
|
||||
tests/db
|
||||
RESULTS="$RESULTS\n$service: $(if [ $? -eq 0 ]; then echo "✅"; else echo "❌"; fi)"
|
||||
done
|
||||
|
||||
echo -e "$RESULTS"
|
||||
test $err = 0
|
||||
|
||||
- name: Run migration check
|
||||
run: |
|
||||
set +e
|
||||
err=0
|
||||
trap 'err=1' ERR
|
||||
|
||||
./tests/db/compose.sh down --volumes --remove-orphans
|
||||
for service in $(./tests/db/compose.sh config --services | grep '^migration-')
|
||||
do
|
||||
./tests/db/compose.sh run --rm --no-TTY $service
|
||||
done
|
||||
|
||||
test $err = 0
|
||||
|
||||
- name: Clean up
|
||||
run: |
|
||||
./tests/db/compose.sh down --volumes --remove-orphans --rmi all
|
||||
|
||||
java:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
with:
|
||||
java-version: 11
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --no-dev
|
||||
- name: Run tests
|
||||
working-directory: mlflow/java
|
||||
run: |
|
||||
uv run --no-dev mvn clean package -q
|
||||
|
||||
flavors:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra extras
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
-r requirements/extra-ml-requirements.txt \
|
||||
'tensorflow<2.21'
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv run --no-sync pytest \
|
||||
tests/tracking/fluent/test_fluent_autolog.py \
|
||||
tests/autologging
|
||||
|
||||
models:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2, 3]
|
||||
include:
|
||||
- splits: 3
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
pyspark langchain langchain-community
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP tests/models
|
||||
|
||||
evaluate:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1]
|
||||
include:
|
||||
- splits: 1
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra extras --extra genai
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
torch transformers pyspark langchain langchain-experimental shap lightgbm xgboost
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP tests/evaluate --ignore=tests/evaluate/test_default_evaluator_delta.py
|
||||
- name: Run tests with delta
|
||||
run: |
|
||||
uv run --no-sync pytest tests/evaluate/test_default_evaluator_delta.py
|
||||
|
||||
genai:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra genai
|
||||
# TODO: migrate mlflow/genai/scorers/phoenix to arize-phoenix-evals 3.x API
|
||||
# (removed HallucinationEvaluator/RelevanceEvaluator/etc. and LiteLLMModel)
|
||||
# TODO: drop the langchain-community<0.4.2 pin once ragas releases a
|
||||
# fix for the removed `langchain_community.chat_models.vertexai`
|
||||
# import (0.4.2 dropped the shim).
|
||||
# https://github.com/vibrantlabsai/ragas/issues/2745
|
||||
#
|
||||
# guardrails-ai is intentionally omitted: it caps typer<0.20 and
|
||||
# click<=8.2.0, which the synced env no longer satisfies, so an
|
||||
# unpinned install backtracks to the ancient 0.1.8 (imports the
|
||||
# removed openai.error). Restore it once upstream lifts the pins.
|
||||
# https://github.com/guardrails-ai/guardrails/issues/1505
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
deepeval ragas 'arize-phoenix-evals<3.0.0' 'langchain-community<0.4.2' trulens trulens-providers-litellm 'google-adk[gcp]'
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run GenAI Tests (OSS)
|
||||
run: |
|
||||
# guardrails-ai is not installed (see the install step), so skip its
|
||||
# tests to keep collection of the rest of tests/genai from breaking.
|
||||
# https://github.com/guardrails-ai/guardrails/issues/1505
|
||||
uv run --no-sync pytest tests/genai \
|
||||
--ignore tests/genai/scorers/guardrails
|
||||
|
||||
- name: Run GenAI Tests (Databricks)
|
||||
run: |
|
||||
# guardrails-ai is not installed (see the install step), so skip its
|
||||
# tests here too. https://github.com/guardrails-ai/guardrails/issues/1505
|
||||
#
|
||||
# test_pytest_integration.py spawns a subprocess pytest from a temp dir,
|
||||
# which imports the published mlflow-skinny that databricks-agents pulls
|
||||
# into this overlay rather than the local checkout. That published build
|
||||
# lacks the new `mlflow.test` API, so the generated test fails to collect.
|
||||
# It already runs against the local mlflow in the OSS step above.
|
||||
uv run --no-sync --with databricks-agents \
|
||||
pytest tests/genai --ignore tests/genai/test_genai_import_without_agent_sdk.py \
|
||||
--ignore tests/genai/optimize --ignore tests/genai/prompts \
|
||||
--ignore tests/genai/scorers/guardrails \
|
||||
--ignore tests/genai/evaluate/test_pytest_integration.py
|
||||
|
||||
pyfunc:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2, 3, 4]
|
||||
include:
|
||||
- splits: 4
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra extras --extra gateway
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
tensorflow 'pyspark[connect]'
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP --durations=30 \
|
||||
tests/pyfunc tests/types --ignore tests/pyfunc/test_spark_connect.py
|
||||
|
||||
# test_spark_connect.py fails if it's run with other tests, so run it separately.
|
||||
uv run --no-sync pytest tests/pyfunc/test_spark_connect.py
|
||||
|
||||
windows:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2, 3]
|
||||
include:
|
||||
- splits: 3
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install python dependencies
|
||||
run: |
|
||||
uv sync --extra extras --extra genai --extra mcp
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
pyspark datasets tensorflow torch transformers openai \
|
||||
tests/resources/mlflow-test-plugin
|
||||
- uses: ./.github/actions/show-versions
|
||||
- uses: ./.github/actions/cache-hf
|
||||
- name: Download Hadoop winutils for Spark
|
||||
run: |
|
||||
git clone https://github.com/cdarlint/winutils /tmp/winutils
|
||||
- name: Run python tests
|
||||
env:
|
||||
# Set Hadoop environment variables required for testing Spark integrations on Windows
|
||||
HADOOP_HOME: /tmp/winutils/hadoop-3.2.2
|
||||
# Use non-GUI matplotlib backend since Tkinter may not be installed properly on Windows runners
|
||||
# https://github.com/stefmolin/data-morph/pull/273
|
||||
MPLBACKEND: Agg
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
export PATH=$PATH:$HADOOP_HOME/bin
|
||||
uv run --no-sync pytest tests \
|
||||
--splits=$SPLITS \
|
||||
--group=$GROUP \
|
||||
--ignore-flavors \
|
||||
--ignore=tests/projects \
|
||||
--ignore=tests/examples \
|
||||
--ignore=tests/evaluate \
|
||||
--ignore=tests/optuna \
|
||||
--ignore=tests/pyspark/optuna \
|
||||
--ignore=tests/genai \
|
||||
--ignore=tests/sagemaker \
|
||||
--ignore=tests/gateway \
|
||||
--ignore=tests/server/auth \
|
||||
--ignore=tests/data/test_spark_dataset.py \
|
||||
--ignore=tests/data/test_spark_dataset_source.py \
|
||||
--ignore=tests/data/test_delta_dataset_source.py \
|
||||
--deselect=tests/data/test_pandas_dataset.py::test_from_pandas_spark_datasource \
|
||||
--deselect=tests/data/test_pandas_dataset.py::test_from_pandas_delta_datasource \
|
||||
--ignore=tests/docker \
|
||||
--ignore=tests/demo
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "clint",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+?):(\\d+):(\\d+):\\s*([a-z][a-z0-9-]+):\\s*(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 5,
|
||||
"code": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "black",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^([^:]+):(\\d+):(\\d+): (Unformatted file\\. .+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "ruff",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+):(\\d+):(\\d+):\\s+([A-Z]+\\d+)\\s+(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 5,
|
||||
"code": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "ty",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+):(\\d+):(\\d+):\\s+error\\[([a-z-]+)\\]\\s+(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"code": 4,
|
||||
"message": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "typos",
|
||||
"severity": "error",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+?):(\\d+):(\\d+):\\s*(.+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
name: NaiLaOpus
|
||||
|
||||
on:
|
||||
# `/review-v2` is the only trigger. We previously also auto-ran on
|
||||
# `pull_request` (opened/ready_for_review/reopened) but pulled it back:
|
||||
# fork PRs (the common case for maintainer PRs) don't get base-repo
|
||||
# secrets on `pull_request` events, and the `pull_request_target`
|
||||
# alternative trips clint's pwn-requests anti-pattern check on
|
||||
# actions/checkout of PR head. Living with the convenience cost of
|
||||
# one typed `/review-v2` comment is cleaner than maintaining a lint
|
||||
# exception or splitting the workflow into LLM-runner + poster jobs.
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
# Single group per PR. cancel-in-progress is false so each /review-v2
|
||||
# invocation finishes; subsequent invocations on the same PR queue
|
||||
# behind it. The orchestrator's head_sha cache dedupes same-SHA runs.
|
||||
group: nailaopus-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# The workflow never uses the default GITHUB_TOKEN — every `gh api`
|
||||
# call goes through the App installation token (steps.app_token.outputs.token).
|
||||
# `permissions:` is set to a minimal `contents: read` purely to satisfy
|
||||
# `.github/policy.rego`'s require-explicit-permissions rule.
|
||||
permissions:
|
||||
contents: read
|
||||
# Two layers of gating, both at the job level so that non-maintainer PRs
|
||||
# never load the App-token secret or run any step at all (no observable
|
||||
# side effects, no review burden on each step before a runtime gate):
|
||||
# 1. Comment author must be a maintainer (prevents random users from
|
||||
# triggering the bot via `/review-v2`).
|
||||
# 2. PR author must be a maintainer (prevents review of community-
|
||||
# authored PRs whose diff content could contain prompt-injection
|
||||
# attempts against the LLM).
|
||||
# `issue_comment` payloads expose both associations directly, so we
|
||||
# can do this check without an extra `gh api` step.
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '/review-v2') &&
|
||||
contains(fromJSON('["MEMBER", "COLLABORATOR", "OWNER"]'), github.event.comment.author_association) &&
|
||||
contains(fromJSON('["MEMBER", "COLLABORATOR", "OWNER"]'), github.event.issue.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Resolve PR number
|
||||
id: ctx
|
||||
env:
|
||||
ISSUE_PR: ${{ github.event.issue.number }}
|
||||
run: echo "pr=$ISSUE_PR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate App installation token (multi-repo)
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ vars.NAILAOPUS_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.NAILAOPUS_APP_KEY }}
|
||||
owner: mlflow
|
||||
# Least-privilege: explicitly request only what the workflow uses
|
||||
# on each repo. The App's installation permissions are the upper
|
||||
# bound; these inputs narrow the issued token further.
|
||||
permission-contents: read
|
||||
permission-pull-requests: write
|
||||
permission-issues: write
|
||||
|
||||
- name: Annotate trigger comment with run URL
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
# Mirrors review.yml's pattern: append a run-URL link to the trigger
|
||||
# comment instead of leaving a separate reaction artifact on the PR.
|
||||
# The maintainer who typed `/review-v2` sees a clickable "🚀 running"
|
||||
# link to follow the job in real time.
|
||||
run: |
|
||||
run_url="$SERVER_URL/$REPO/actions/runs/$RUN_ID"
|
||||
orig=$(gh api "repos/$REPO/issues/comments/$COMMENT_ID" --jq .body)
|
||||
body=$(printf '%s\n\n---\n\n🚀 [NaiLaOpus running...](%s)' "$orig" "$run_url")
|
||||
gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$body" || true
|
||||
|
||||
- name: Enforce per-PR daily rate limit
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
PR: ${{ steps.ctx.outputs.pr }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
since=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-24H '+%Y-%m-%dT%H:%M:%SZ')
|
||||
count=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
|
||||
--jq "[.[] | select(.user.login == \"NaiLaOpus[bot]\") | select(.created_at > \"$since\")] | length")
|
||||
if [ "$count" -ge 5 ]; then
|
||||
echo "Rate limit hit: $count bot comments in the last 24h on PR $PR (max 5)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Tag PR as reviewed by NaiLaOpus
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
PR: ${{ steps.ctx.outputs.pr }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# The `nailaopus-reviewed` label is created out-of-band as part of
|
||||
# the repo-admin rollout; we assume it exists here.
|
||||
gh issue edit "$PR" \
|
||||
--repo "$REPO" \
|
||||
--add-label nailaopus-reviewed || true
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: refs/pull/${{ steps.ctx.outputs.pr }}/head
|
||||
# Full checkout (shallow by default) so the orchestrator's agents
|
||||
# can use Read/Grep/Glob bounded to this worktree via
|
||||
# `--mlflow-tree "$GITHUB_WORKSPACE/mlflow"` below. The agents grep
|
||||
# sibling files at PR head to validate or refute concerns, which
|
||||
# gave a meaningful recall lift in the 81-PR eval vs. API-only
|
||||
# reads.
|
||||
path: mlflow
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
# Read-only checkout; we never push from the workflow. Avoid
|
||||
# persisting the App token into .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout mlflow/internal (orchestrator code)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: mlflow/internal
|
||||
# Pinned to a specific commit so production reviews are reproducible
|
||||
# and a bad orchestrator change can't silently break the bot. To bump,
|
||||
# open a new PR that updates this SHA to the latest mlflow/internal
|
||||
# main after reviewing the diff. We'll migrate to release tags once
|
||||
# the bump cadence is stable; see mlflow/internal for the policy.
|
||||
ref: d4ac1fd5e110dbbe3f00db09b65b53d06d022d66
|
||||
sparse-checkout: |
|
||||
nailaopus
|
||||
path: internal
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Parse review flags
|
||||
id: flags
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
flags=""
|
||||
if echo "$BODY" | grep -q -- '--no-cache'; then flags="$flags --no-cache"; fi
|
||||
if echo "$BODY" | grep -q -- '--hybrid'; then flags="$flags --hybrid"; fi
|
||||
echo "flags=$flags" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run NaiLaOpus
|
||||
env:
|
||||
# EXPERIMENTAL_ANTHROPIC_API_KEY isolates the bot's spend from
|
||||
# any other workflow on this repo that uses Anthropic (e.g. the
|
||||
# existing `review.yml`'s ANTHROPIC_API_KEY). The orchestrator
|
||||
# reads from $ANTHROPIC_API_KEY, so the env var name passed to
|
||||
# the process stays standard.
|
||||
ANTHROPIC_API_KEY: ${{ secrets.EXPERIMENTAL_ANTHROPIC_API_KEY }}
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
PR: ${{ steps.ctx.outputs.pr }}
|
||||
REPO: ${{ github.repository }}
|
||||
FLAGS: ${{ steps.flags.outputs.flags }}
|
||||
# MLflow tracing. Unset repo vars/secrets resolve to empty strings,
|
||||
# so tracing stays off until these are populated with non-empty
|
||||
# values; flipping NAILAOPUS_MLFLOW_ENABLE_TRACING toggles it
|
||||
# without editing this file.
|
||||
MLFLOW_ENABLE_TRACING: ${{ vars.NAILAOPUS_MLFLOW_ENABLE_TRACING }}
|
||||
MLFLOW_TRACKING_URI: ${{ vars.NAILAOPUS_MLFLOW_TRACKING_URI }}
|
||||
MLFLOW_EXPERIMENT_ID: ${{ secrets.NAILAOPUS_MLFLOW_EXPERIMENT_ID }}
|
||||
DATABRICKS_HOST: ${{ secrets.NAILAOPUS_DATABRICKS_HOST }}
|
||||
DATABRICKS_CLIENT_ID: ${{ secrets.NAILAOPUS_DATABRICKS_CLIENT_ID }}
|
||||
DATABRICKS_CLIENT_SECRET: ${{ secrets.NAILAOPUS_DATABRICKS_CLIENT_SECRET }}
|
||||
# Inputs flow through env vars rather than ${{ }} interpolation
|
||||
# directly into the shell, so an unexpected character in any value
|
||||
# (today none reach here, but the pattern is hardening for drift)
|
||||
# is treated as data, not as code. $FLAGS is intentionally unquoted
|
||||
# so it word-splits into separate argv entries.
|
||||
# `uv run --directory` resolves + runs the `nailaopus` console script
|
||||
# from the orchestrator package without a separate `uv pip install`
|
||||
# step; uv caches the env between invocations.
|
||||
run: |
|
||||
uv run --directory internal/nailaopus nailaopus "$PR" --repo "$REPO" --mlflow-tree "$GITHUB_WORKSPACE/mlflow" --no-dry-run $FLAGS
|
||||
|
||||
- name: React +1 on success
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
run: |
|
||||
gh api \
|
||||
"repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
|
||||
-X POST -f content=+1 || true
|
||||
|
||||
- name: Post failure comment
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ steps.ctx.outputs.pr }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
run_url="$SERVER_URL/$REPO/actions/runs/$RUN_ID"
|
||||
# printf with a single-quoted format string keeps backticks literal
|
||||
# (no command substitution) and avoids the multi-line YAML quoting
|
||||
# bug where embedded backticks broke shell parsing. The leading ❌
|
||||
# is the signal a per-comment reaction used to give; consolidating
|
||||
# into one comment cuts an API call and reduces rate-limit pressure.
|
||||
body=$(printf '❌ `NaiLaOpus` failed. See [run logs](%s).\n\n---\nPosted by `NaiLaOpus` (auto-generated).' "$run_url")
|
||||
gh api \
|
||||
"repos/$REPO/issues/$PR/comments" \
|
||||
-X POST -f body="$body" || true
|
||||
@@ -0,0 +1,133 @@
|
||||
// Helper function to parse semantic version for comparison
|
||||
function parseSemanticVersion(version) {
|
||||
const versionStr = version.replace(/^v/, "");
|
||||
const cleanVersion = versionStr.replace(/rc\d+$/, "");
|
||||
const parts = cleanVersion.split(".").map(Number);
|
||||
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
micro: parts[2] || 0,
|
||||
isRc: versionStr.includes("rc"),
|
||||
original: version,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to compare versions for sorting
|
||||
function compareVersions(a, b) {
|
||||
const versionA = parseSemanticVersion(a.tag_name);
|
||||
const versionB = parseSemanticVersion(b.tag_name);
|
||||
|
||||
// Compare major.minor.micro in descending order
|
||||
if (versionA.major !== versionB.major) {
|
||||
return versionB.major - versionA.major;
|
||||
}
|
||||
if (versionA.minor !== versionB.minor) {
|
||||
return versionB.minor - versionA.minor;
|
||||
}
|
||||
if (versionA.micro !== versionB.micro) {
|
||||
return versionB.micro - versionA.micro;
|
||||
}
|
||||
|
||||
// If versions are equal, prefer non-rc over rc
|
||||
if (versionA.isRc !== versionB.isRc) {
|
||||
return versionA.isRc ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = async ({ context, github, core }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { base, number: pull_number } = context.payload.pull_request;
|
||||
if (base.ref.match(/^branch-\d+\.\d+$/)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
const { body } = pr.data;
|
||||
|
||||
// Skip running this check if PR is filed by a bot (except GitHub Copilot)
|
||||
if (pr.data.user?.type?.toLowerCase() === "bot" && pr.data.user?.login !== "Copilot") {
|
||||
core.info(
|
||||
`Skipping processing because the PR is filed by a bot: ${pr.data.user?.login || "unknown"}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip running this check on CD automation PRs
|
||||
if (!body || body.trim() === "") {
|
||||
core.info("Skipping processing because the PR has no body.");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: remove the "yes/no" alternatives once all open PRs have switched to the new template
|
||||
const yesRegex = /- \[( |x)\] (yes \()?this PR (will be|is critical)/gi;
|
||||
const yesMatches = [...body.matchAll(yesRegex)];
|
||||
const yesMatch = yesMatches.length > 0 ? yesMatches[yesMatches.length - 1] : null;
|
||||
const yes = yesMatch ? yesMatch[1].toLowerCase() === "x" : false;
|
||||
// TODO: remove the "yes/no" alternatives once all open PRs have switched to the new template
|
||||
const noRegex = /- \[( |x)\] (no \()?this PR (will be|can wait)/gi;
|
||||
const noMatches = [...body.matchAll(noRegex)];
|
||||
const noMatch = noMatches.length > 0 ? noMatches[noMatches.length - 1] : null;
|
||||
const no = noMatch ? noMatch[1].toLowerCase() === "x" : false;
|
||||
|
||||
if (yes && no) {
|
||||
core.setFailed(
|
||||
"Both yes and no are selected. Please select only one in the patch release section."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes && !no) {
|
||||
core.setFailed("Please fill in the patch release section.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (no) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a version label already exists
|
||||
const existingLabels = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
const versionLabelPattern = /^v\d+\.\d+\.\d+$/;
|
||||
const existingVersionLabel = existingLabels.data.find((label) =>
|
||||
versionLabelPattern.test(label.name)
|
||||
);
|
||||
|
||||
if (existingVersionLabel) {
|
||||
core.info(
|
||||
`Version label ${existingVersionLabel.name} already exists on this PR. Skipping label addition.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const releases = await github.rest.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
|
||||
// Filter version tags that start with 'v', sort by semantic version, and select the latest
|
||||
const versionReleases = releases.data.filter(({ tag_name }) => tag_name.startsWith("v"));
|
||||
const sortedReleases = versionReleases.sort(compareVersions);
|
||||
const latest = sortedReleases[0];
|
||||
const version = latest.tag_name.replace("v", "");
|
||||
const [major, minor, micro] = version.replace(/rc\d+$/, "").split(".");
|
||||
const nextMicro = version.includes("rc") ? micro : (parseInt(micro) + 1).toString();
|
||||
const label = `v${major}.${minor}.${nextMicro}`;
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [label],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Patch
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
patch:
|
||||
if: (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/patch.js');
|
||||
await script({ github, context, core });
|
||||
@@ -0,0 +1,136 @@
|
||||
const GENERATED = [
|
||||
/^mlflow\/protos\/.*\.pyi$/,
|
||||
/_pb2(_grpc)?\.py$/,
|
||||
/^uv\.lock$/,
|
||||
/package-lock\.json$/,
|
||||
/yarn\.lock$/,
|
||||
/^docs\/api_reference\/source\/rest-api\.rst$/,
|
||||
];
|
||||
|
||||
const THRESHOLDS = {
|
||||
XS: 9,
|
||||
S: 49,
|
||||
M: 199,
|
||||
L: 499,
|
||||
XL: Infinity,
|
||||
};
|
||||
|
||||
async function isGenerated(owner, repo, sha, filename) {
|
||||
if (GENERATED.some((p) => p.test(filename))) return true;
|
||||
if (filename.endsWith(".java")) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://raw.githubusercontent.com/${owner}/${repo}/${sha}/${filename}`,
|
||||
{ headers: { Range: "bytes=0-255", Authorization: `token ${process.env.GH_TOKEN}` } }
|
||||
);
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text.includes("Generated by the protocol buffer compiler")) return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(filename, e.message);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSize(total) {
|
||||
return Object.entries(THRESHOLDS).find(([, max]) => total <= max)[0];
|
||||
}
|
||||
|
||||
function extractStackedPrBaseSha(body, headRef) {
|
||||
if (!body || !body.includes("Stacked PR")) return null;
|
||||
const marker = `[**${headRef}**]`;
|
||||
for (const line of body.split("\n")) {
|
||||
if (line.includes(marker)) {
|
||||
const match = line.match(/\/files\/([a-f0-9]{7,40})\.\.([a-f0-9]{7,40})/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getFiles(github, owner, repo, pr) {
|
||||
const baseSha = extractStackedPrBaseSha(pr.body, pr.head.ref);
|
||||
if (baseSha) {
|
||||
console.log(`Stacked PR detected, using incremental diff: ${baseSha}..${pr.head.sha}`);
|
||||
try {
|
||||
const resp = await github.rest.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base: baseSha,
|
||||
head: pr.head.sha,
|
||||
});
|
||||
return resp.data.files;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch incremental diff, falling back to full diff: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const headSha = pr.head.sha;
|
||||
const files = await getFiles(github, owner, repo, pr);
|
||||
|
||||
let total = 0;
|
||||
const maxThreshold = Math.max(...Object.values(THRESHOLDS).filter(isFinite));
|
||||
for (const f of files) {
|
||||
if (!(await isGenerated(owner, repo, headSha, f.filename))) {
|
||||
total += f.additions + f.deletions;
|
||||
}
|
||||
if (total > maxThreshold) break;
|
||||
}
|
||||
|
||||
const sizeLabel = `size/${getSize(total)}`;
|
||||
console.log(`Size: ${total} lines -> ${sizeLabel}`);
|
||||
|
||||
// Manage labels
|
||||
const currentLabels = (
|
||||
await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
})
|
||||
).map((l) => l.name);
|
||||
|
||||
// Remove stale size labels
|
||||
for (const label of currentLabels) {
|
||||
if (label.startsWith("size/") && label !== sizeLabel) {
|
||||
console.log(`Removing stale label: ${label}`);
|
||||
await github.rest.issues
|
||||
.removeLabel({ owner, repo, issue_number: pr.number, name: label })
|
||||
.catch((e) => console.warn(`Failed to remove label ${label}: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Add the correct label if not already present
|
||||
if (!currentLabels.includes(sizeLabel)) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: sizeLabel });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
console.log(`Creating label: ${sizeLabel}`);
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: sizeLabel,
|
||||
color: "ededed",
|
||||
description: `Pull request size: ${sizeLabel.replace("size/", "")}`,
|
||||
});
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [sizeLabel],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
name: PR Size Labeling
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- closed
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-pr-size:
|
||||
if: (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/workflows/pr-size.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/pr-size.js');
|
||||
await script({ github, context });
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Script to manage documentation preview comments on pull requests.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
|
||||
const MARKER = "<!-- documentation preview -->";
|
||||
|
||||
/**
|
||||
* Check if a URL is accessible
|
||||
* @param {string} url - URL to check
|
||||
* @returns {Promise<boolean|undefined>} True if the URL is accessible (200 or 3xx), false otherwise; undefined if an error occurs
|
||||
*/
|
||||
async function isUrlAccessible(url) {
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET", redirect: "manual" });
|
||||
// treat 200 and 3xx as "accessible", 404 as "missing"
|
||||
return res.ok || (res.status >= 300 && res.status < 400);
|
||||
} catch (e) {
|
||||
console.error(`Error checking accessibility for ${url}:`, e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch changed files from a pull request
|
||||
* @param {object} params - Parameters object
|
||||
* @param {object} params.github - GitHub API client
|
||||
* @param {string} params.owner - Repository owner
|
||||
* @param {string} params.repo - Repository name
|
||||
* @param {string} params.pullNumber - Pull request number
|
||||
* @returns {Promise<Array<{filename: string, status: string}>>} Array of changed file objects with filename and status
|
||||
*/
|
||||
async function fetchChangedFiles({ github, owner, repo, pullNumber }) {
|
||||
const iterator = github.paginate.iterator(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const changedFiles = [];
|
||||
for await (const { data } of iterator) {
|
||||
changedFiles.push(...data.map(({ filename, status }) => ({ filename, status })));
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changed documentation pages from the list of changed files
|
||||
* @param {Array<{filename: string, status: string}>} changedFiles - Array of changed file objects
|
||||
* @returns {Array<{page: string, status: string}>} Array of documentation page objects with page path and status, sorted alphabetically by page path
|
||||
*/
|
||||
function getChangedDocPages(changedFiles) {
|
||||
const DOCS_DIR = "docs/docs/";
|
||||
const changedPages = [];
|
||||
|
||||
for (const { filename, status } of changedFiles) {
|
||||
const ext = path.extname(filename);
|
||||
if (ext !== ".md" && ext !== ".mdx") continue;
|
||||
if (!filename.startsWith(DOCS_DIR)) continue;
|
||||
|
||||
const relativePath = path.relative(DOCS_DIR, filename);
|
||||
const { dir, name, base } = path.parse(relativePath);
|
||||
|
||||
let pagePath;
|
||||
if (base === "index.mdx") {
|
||||
pagePath = dir;
|
||||
} else {
|
||||
pagePath = path.join(dir, name);
|
||||
}
|
||||
|
||||
// Adjust classic-ml/ to ml/
|
||||
pagePath = pagePath.replace(/^classic-ml/, "ml");
|
||||
|
||||
// Ensure forward slashes for web paths
|
||||
pagePath = pagePath.split(path.sep).join("/");
|
||||
|
||||
changedPages.push({ page: pagePath, status });
|
||||
}
|
||||
|
||||
// Sort alphabetically by page path for easier lookup
|
||||
changedPages.sort((a, b) => a.page.localeCompare(b.page));
|
||||
|
||||
return changedPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a PR comment with documentation preview information
|
||||
* @param {object} params - Parameters object
|
||||
* @param {object} params.github - GitHub API client
|
||||
* @param {string} params.owner - Repository owner
|
||||
* @param {string} params.repo - Repository name
|
||||
* @param {string} params.pullNumber - Pull request number
|
||||
* @param {string} params.commentBody - Comment body content
|
||||
*/
|
||||
async function upsertComment({ github, owner, repo, pullNumber, commentBody }) {
|
||||
// Get existing comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
// Find existing preview docs comment
|
||||
const existingComment = comments.find((comment) => comment.body.includes(MARKER));
|
||||
const commentBodyWithMarker = `${MARKER}\n\n${commentBody}`;
|
||||
|
||||
if (!existingComment) {
|
||||
console.log("Creating comment");
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullNumber,
|
||||
body: commentBodyWithMarker,
|
||||
});
|
||||
} else {
|
||||
console.log("Updating comment");
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
body: commentBodyWithMarker,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the comment template for documentation preview
|
||||
* @param {object} params - Parameters object
|
||||
* @param {string} params.commitSha - Git commit SHA
|
||||
* @param {string} params.workflowRunLink - Link to the workflow run
|
||||
* @param {string} params.docsWorkflowRunUrl - Link to the docs workflow run
|
||||
* @param {string} params.mainMessage - Main message content
|
||||
* @param {Array<{link: string, status: string, isAccessible?: boolean}>} params.changedPages - Array of changed documentation page objects with link, status, and optional accessibility flag
|
||||
* @returns {string} Comment template
|
||||
*/
|
||||
function getCommentTemplate({
|
||||
commitSha,
|
||||
workflowRunLink,
|
||||
docsWorkflowRunUrl,
|
||||
mainMessage,
|
||||
changedPages,
|
||||
}) {
|
||||
let changedPagesSection = "";
|
||||
|
||||
if (changedPages && changedPages.length > 0) {
|
||||
const pageLinks = changedPages
|
||||
.map(({ link, status, isAccessible }) => {
|
||||
let statusText = status;
|
||||
// Add warning or success indicator for removed and renamed files
|
||||
if (status === "removed" || status === "renamed") {
|
||||
if (isAccessible === true) {
|
||||
statusText = `${status}, ✅ redirect exists`;
|
||||
} else if (isAccessible === false) {
|
||||
statusText = `${status}, ❌ add a redirect`;
|
||||
} else {
|
||||
// If accessibility check failed
|
||||
statusText = `${status}, ⚠️ failed to check`;
|
||||
}
|
||||
}
|
||||
return `- ${link} (${statusText})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Only collapse if there are more than 5 changed pages
|
||||
if (changedPages.length > 5) {
|
||||
changedPagesSection = `
|
||||
|
||||
<details>
|
||||
<summary>Changed Pages (${changedPages.length})</summary>
|
||||
|
||||
${pageLinks}
|
||||
|
||||
</details>
|
||||
`;
|
||||
} else {
|
||||
changedPagesSection = `
|
||||
|
||||
**Changed Pages (${changedPages.length})**
|
||||
|
||||
${pageLinks}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
Documentation preview for ${commitSha} ${mainMessage}
|
||||
${changedPagesSection}
|
||||
<details>
|
||||
<summary>More info</summary>
|
||||
|
||||
- Ignore this comment if this PR does not change the documentation.
|
||||
- The preview is updated when a new commit is pushed to this PR.
|
||||
- This comment was created by [this workflow run](${workflowRunLink}).
|
||||
- The documentation was built by [this workflow run](${docsWorkflowRunUrl}).
|
||||
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to handle documentation preview comments
|
||||
* @param {object} params - Parameters object containing context and github
|
||||
* @param {object} params.github - GitHub API client
|
||||
* @param {object} params.context - GitHub context
|
||||
* @param {object} params.env - Environment variables
|
||||
*/
|
||||
module.exports = async ({ github, context, env }) => {
|
||||
const commitSha = env.COMMIT_SHA;
|
||||
const pullNumber = env.PULL_NUMBER;
|
||||
const workflowRunId = env.WORKFLOW_RUN_ID;
|
||||
const stage = env.STAGE;
|
||||
const netlifyUrl = env.NETLIFY_URL;
|
||||
const docsWorkflowRunUrl = env.DOCS_WORKFLOW_RUN_URL;
|
||||
|
||||
// Validate required parameters
|
||||
if (!commitSha || !pullNumber || !workflowRunId || !stage || !docsWorkflowRunUrl) {
|
||||
throw new Error(
|
||||
"Missing required parameters: commit-sha, pull-number, workflow-run-id, stage, docs-workflow-run-url"
|
||||
);
|
||||
}
|
||||
|
||||
if (!["completed", "failed"].includes(stage)) {
|
||||
throw new Error("Stage must be either 'completed' or 'failed'");
|
||||
}
|
||||
|
||||
if (stage === "completed" && !netlifyUrl) {
|
||||
throw new Error("netlify-url is required for completed stage");
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowRunLink = `https://github.com/${owner}/${repo}/actions/runs/${workflowRunId}`;
|
||||
|
||||
let mainMessage;
|
||||
let changedPages = [];
|
||||
|
||||
if (stage === "completed") {
|
||||
mainMessage = `is available at:\n\n- ${netlifyUrl}`;
|
||||
|
||||
// Fetch changed files and get documentation pages
|
||||
try {
|
||||
const changedFiles = await fetchChangedFiles({ github, owner, repo, pullNumber });
|
||||
const docPages = getChangedDocPages(changedFiles);
|
||||
|
||||
// Convert to clickable links with status if we have changed pages
|
||||
if (docPages.length > 0) {
|
||||
changedPages = await Promise.all(
|
||||
docPages.map(async ({ page, status }) => {
|
||||
const pageUrl = `${netlifyUrl}/${page}`;
|
||||
const link = `[${page}](${pageUrl})`;
|
||||
|
||||
// Check accessibility for removed or renamed pages
|
||||
let isAccessible;
|
||||
if (status === "removed" || status === "renamed") {
|
||||
isAccessible = await isUrlAccessible(pageUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
link,
|
||||
status,
|
||||
isAccessible,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching changed files:", error);
|
||||
// Continue without changed pages list
|
||||
}
|
||||
} else if (stage === "failed") {
|
||||
mainMessage = "failed to build or deploy.";
|
||||
}
|
||||
|
||||
const commentBody = getCommentTemplate({
|
||||
commitSha,
|
||||
workflowRunLink,
|
||||
docsWorkflowRunUrl,
|
||||
mainMessage,
|
||||
changedPages,
|
||||
});
|
||||
await upsertComment({ github, owner, repo, pullNumber, commentBody });
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Preview docs
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [docs]
|
||||
types: [completed]
|
||||
|
||||
env:
|
||||
DOCS_BUILD_DIR: /tmp/docs-build
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
main:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # To post comments on PRs
|
||||
actions: write # To delete artifacts
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Set alias
|
||||
id: alias
|
||||
env:
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ "$EVENT" = "push" ]; then
|
||||
ALIAS="dev"
|
||||
else
|
||||
# Derive PR number from the workflow run's head branch via API
|
||||
# instead of trusting the artifact (which fork PRs can manipulate)
|
||||
PR_NUMBER=$(gh api "repos/$REPO/pulls?head=$HEAD_OWNER:$HEAD_BRANCH&state=open" \
|
||||
--jq '.[0].number // empty')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "::error::Could not find PR for $HEAD_OWNER:$HEAD_BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
ALIAS="pr-$PR_NUMBER"
|
||||
fi
|
||||
echo "value=$ALIAS" >> $GITHUB_OUTPUT
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: docs-build-${{ github.event.workflow_run.id }}
|
||||
path: ${{ env.DOCS_BUILD_DIR }}
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Deploy to Netlify
|
||||
id: netlify_deploy
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
ALIAS: ${{ steps.alias.outputs.value }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |-
|
||||
OUTPUT=$(npx --min-release-age=7 --yes netlify-cli deploy \
|
||||
--dir="$DOCS_BUILD_DIR" \
|
||||
--no-build \
|
||||
--message="Preview ${ALIAS} - GitHub Run ID: $RUN_ID" \
|
||||
--alias="${ALIAS}" \
|
||||
--json)
|
||||
if ! DEPLOY_URL=$(echo "$OUTPUT" | jq -re '.deploy_url'); then
|
||||
echo "::error::Failed to parse deploy output"
|
||||
echo "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
echo "deploy_url=${DEPLOY_URL}/docs/latest/" >> $GITHUB_OUTPUT
|
||||
continue-on-error: true
|
||||
- name: Extract PR number
|
||||
id: pr_number
|
||||
if: startsWith(steps.alias.outputs.value, 'pr-')
|
||||
env:
|
||||
ALIAS: ${{ steps.alias.outputs.value }}
|
||||
run: |
|
||||
# Extract number from alias (e.g., "pr-123" -> "123")
|
||||
PR_NUM=$(echo "$ALIAS" | sed 's/^pr-//')
|
||||
echo "value=$PR_NUM" >> $GITHUB_OUTPUT
|
||||
echo "Extracted PR number: $PR_NUM"
|
||||
- name: Create preview link
|
||||
if: startsWith(steps.alias.outputs.value, 'pr-')
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
PULL_NUMBER: ${{ steps.pr_number.outputs.value }}
|
||||
WORKFLOW_RUN_ID: ${{ github.run_id }}
|
||||
STAGE: ${{ steps.netlify_deploy.outcome == 'success' && 'completed' || 'failed' }}
|
||||
NETLIFY_URL: ${{ steps.netlify_deploy.outputs.deploy_url }}
|
||||
DOCS_WORKFLOW_RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/preview-comment.js`
|
||||
);
|
||||
await script({ context, github, env: process.env });
|
||||
- name: Delete Build Artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
ARTIFACT_NAME: docs-build-${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
ARTIFACT_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID/artifacts?name=$ARTIFACT_NAME" \
|
||||
--jq '.artifacts[0].id // empty')
|
||||
if [ -z "$ARTIFACT_ID" ]; then
|
||||
echo "::error::Artifact $ARTIFACT_NAME not found for run $RUN_ID"
|
||||
exit 1
|
||||
fi
|
||||
gh api -X DELETE "repos/$REPO/actions/artifacts/$ARTIFACT_ID"
|
||||
@@ -0,0 +1,192 @@
|
||||
function getSleepLength(iterationCount, numPendingJobs) {
|
||||
if (iterationCount <= 5 && numPendingJobs <= 5) {
|
||||
// It's likely that this job was triggered with other quick jobs.
|
||||
// To minimize the wait time, shorten the polling interval for the first 5 iterations.
|
||||
return 5 * 1000; // 5 seconds
|
||||
}
|
||||
// If the number of pending jobs is small, poll more frequently to reduce wait time.
|
||||
return (numPendingJobs <= 7 ? 30 : 5 * 60) * 1000;
|
||||
}
|
||||
module.exports = async ({ github, context }) => {
|
||||
const {
|
||||
repo: { owner, repo },
|
||||
} = context;
|
||||
const { sha } = context.payload.pull_request.head;
|
||||
|
||||
const STATE = {
|
||||
pending: "pending",
|
||||
success: "success",
|
||||
failure: "failure",
|
||||
};
|
||||
|
||||
const IGNORED_WORKFLOWS = new Set([".github/workflows/rerun.yml"]);
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function logRateLimit() {
|
||||
const { data: rateLimit } = await github.rest.rateLimit.get();
|
||||
console.log(`Rate limit remaining: ${rateLimit.resources.core.remaining}`);
|
||||
}
|
||||
|
||||
function isNewerRun(newRun, existingRun) {
|
||||
// Returns true if newRun should replace existingRun
|
||||
if (!existingRun) return true;
|
||||
|
||||
// If they are different workflow runs, prefer the one with a higher ID (auto-incrementing)
|
||||
if (newRun.id !== existingRun.id) {
|
||||
return newRun.id > existingRun.id;
|
||||
}
|
||||
|
||||
// Same workflow run: higher run_attempt takes priority (re-runs)
|
||||
return newRun.run_attempt > existingRun.run_attempt;
|
||||
}
|
||||
function isJobFailed({ status, conclusion }) {
|
||||
return (
|
||||
conclusion === "cancelled" ||
|
||||
(status === "completed" && conclusion !== "success" && conclusion !== "skipped")
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchChecks(ref) {
|
||||
// Check runs (e.g., DCO check, but excluding GitHub Actions)
|
||||
const checkRuns = (
|
||||
await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
})
|
||||
).filter(({ app }) => app?.slug !== "github-actions");
|
||||
|
||||
const latestCheckRuns = {};
|
||||
for (const run of checkRuns) {
|
||||
const { name } = run;
|
||||
if (
|
||||
!latestCheckRuns[name] ||
|
||||
new Date(run.started_at) > new Date(latestCheckRuns[name].started_at)
|
||||
) {
|
||||
latestCheckRuns[name] = run;
|
||||
}
|
||||
}
|
||||
const checks = Object.values(latestCheckRuns).map(({ name, status, conclusion, html_url }) => ({
|
||||
name,
|
||||
url: html_url,
|
||||
pendingJobs: 0,
|
||||
status:
|
||||
conclusion === "cancelled"
|
||||
? STATE.failure
|
||||
: status !== "completed"
|
||||
? STATE.pending
|
||||
: conclusion === "success" || conclusion === "skipped"
|
||||
? STATE.success
|
||||
: STATE.failure,
|
||||
}));
|
||||
|
||||
// Workflow runs (e.g., GitHub Actions)
|
||||
const workflowRuns = (
|
||||
await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
head_sha: ref,
|
||||
per_page: 100,
|
||||
})
|
||||
).filter(
|
||||
({ path, event }) =>
|
||||
// Exclude this workflow to avoid self-checking
|
||||
path !== ".github/workflows/protect.yml" &&
|
||||
// Exclude dynamic workflows (GitHub-managed, e.g., Copilot code review)
|
||||
event !== "dynamic" &&
|
||||
!IGNORED_WORKFLOWS.has(path)
|
||||
);
|
||||
|
||||
// Deduplicate workflow runs by path and event, keeping the latest attempt
|
||||
const latestRuns = {};
|
||||
for (const run of workflowRuns) {
|
||||
const { path, event } = run;
|
||||
const key = `${path}-${event}`;
|
||||
if (isNewerRun(run, latestRuns[key])) {
|
||||
latestRuns[key] = run;
|
||||
}
|
||||
}
|
||||
|
||||
for (const run of Object.values(latestRuns)) {
|
||||
const runName = run.path.replace(".github/workflows/", "");
|
||||
if (run.status === "completed") {
|
||||
// Use run-level status directly (0 extra API calls).
|
||||
checks.push({
|
||||
name: `${run.name} (${runName}, attempt ${run.run_attempt})`,
|
||||
url: run.html_url,
|
||||
pendingJobs: 0,
|
||||
status:
|
||||
run.conclusion === "cancelled"
|
||||
? STATE.failure
|
||||
: run.conclusion === "success" || run.conclusion === "skipped"
|
||||
? STATE.success
|
||||
: STATE.failure,
|
||||
});
|
||||
} else {
|
||||
let failed = false;
|
||||
let pendingJobs = 0;
|
||||
const jobsIter = github.paginate.iterator(github.rest.actions.listJobsForWorkflowRun, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
for await (const { data: jobs } of jobsIter) {
|
||||
if (jobs.some(isJobFailed)) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
pendingJobs += jobs.filter(({ status }) => status !== "completed").length;
|
||||
}
|
||||
checks.push({
|
||||
name: `${run.name} (${runName}, attempt ${run.run_attempt})`,
|
||||
url: run.html_url,
|
||||
pendingJobs: failed ? 0 : pendingJobs,
|
||||
status: failed ? STATE.failure : STATE.pending,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
const start = new Date();
|
||||
let iterationCount = 0;
|
||||
const TIMEOUT = 120 * 60 * 1000; // 2 hours
|
||||
await logRateLimit();
|
||||
while (new Date() - start < TIMEOUT) {
|
||||
++iterationCount;
|
||||
const checks = await fetchChecks(sha);
|
||||
const longest = Math.max(...checks.map(({ name }) => name.length));
|
||||
checks.forEach(({ name, status, url }) => {
|
||||
const icon = status === STATE.success ? "✅" : status === STATE.failure ? "❌" : "🕒";
|
||||
console.log(`- ${name.padEnd(longest)}: ${icon} ${status}${url ? ` (${url})` : ""}`);
|
||||
});
|
||||
|
||||
if (checks.some(({ status }) => status === STATE.failure)) {
|
||||
throw new Error(
|
||||
"This job ensures that all checks except for this one have passed to prevent accidental auto-merges."
|
||||
);
|
||||
}
|
||||
|
||||
if (checks.length > 0 && checks.every(({ status }) => status === STATE.success)) {
|
||||
console.log("All checks passed");
|
||||
return;
|
||||
}
|
||||
|
||||
await logRateLimit();
|
||||
const pendingJobs = checks
|
||||
.filter(({ status }) => status === STATE.pending)
|
||||
.reduce((sum, check) => sum + check.pendingJobs, 0);
|
||||
const sleepLength = getSleepLength(iterationCount, pendingJobs);
|
||||
console.log(`Sleeping for ${sleepLength / 1000} seconds (${pendingJobs} pending jobs)`);
|
||||
await sleep(sleepLength);
|
||||
}
|
||||
|
||||
throw new Error("Timeout");
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
# This job prevents accidental auto-merging of PRs when jobs that are conditionally
|
||||
# triggered (for example, those defined in `cross-version-tests.yml`) are either still
|
||||
# in the process of running or have resulted in failures.
|
||||
name: Protect
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
protect:
|
||||
# Skip this job in a merge queue
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
checks: read # to read check statuses
|
||||
actions: read # to read workflow runs and jobs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/protect.js');
|
||||
await script({ github, context });
|
||||
@@ -0,0 +1,98 @@
|
||||
name: protobuf cross tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- "mlflow/protos/**"
|
||||
- .github/workflows/protobuf-cross-test.yml
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
paths:
|
||||
- "mlflow/protos/**"
|
||||
- .github/workflows/protobuf-cross-test.yml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Use `bash` by default for all `run` steps in this workflow:
|
||||
# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#defaultsrun
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
# Note miniconda is pre-installed in the virtual environments for GitHub Actions:
|
||||
# https://github.com/actions/virtual-environments/blob/main/images/linux/scripts/installers/miniconda.sh
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
SPARK_LOCAL_IP: localhost
|
||||
PYTHONUTF8: "1"
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
core_tests:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2]
|
||||
protobuf_major_version: [3, 4, 5]
|
||||
include:
|
||||
- splits: 2
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
env:
|
||||
PROTOBUF_MAJOR_VERSION: ${{ matrix.protobuf_major_version }}
|
||||
run: |
|
||||
uv sync --extra extras --extra genai --extra mcp
|
||||
# Test the latest minor version in protobuf_major_version
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
datasets \
|
||||
'tensorflow<2.22.0' \
|
||||
torch transformers \
|
||||
"protobuf==$PROTOBUF_MAJOR_VERSION.*"
|
||||
- uses: ./.github/actions/show-versions
|
||||
- uses: ./.github/actions/cache-hf
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
# NB: test_mlflow_artifacts.py is excluded because it runs docker-compose with fresh
|
||||
# container builds. The protobuf version in the test environment has no effect on the
|
||||
# containers, making cross-version testing unnecessary and wasteful of disk space.
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP \
|
||||
--ignore-flavors \
|
||||
--ignore=tests/projects \
|
||||
--ignore=tests/examples \
|
||||
--ignore=tests/evaluate \
|
||||
--ignore=tests/optuna \
|
||||
--ignore=tests/pyspark/optuna \
|
||||
--ignore=tests/genai \
|
||||
--ignore=tests/telemetry \
|
||||
--ignore=tests/gateway \
|
||||
--ignore=tests/tracking/test_mlflow_artifacts.py \
|
||||
tests
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Protos
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- .github/workflows/protos.yml
|
||||
- dev/gen_rest_api.py
|
||||
- dev/generate_protos.py
|
||||
- dev/generate-protos.sh
|
||||
- dev/test-generate-protos.sh
|
||||
- mlflow/protos/**
|
||||
- mlflow/java/client/src/main/java/com/databricks/api/proto/**
|
||||
# graphql related code changes could trigger changes in the autogenerated schema
|
||||
- mlflow/server/graphql/**
|
||||
- mlflow/server/js/src/graphql/**
|
||||
- requirements/skinny-requirements.yaml
|
||||
- requirements/core-requirements.yaml
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
protos:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Check OpenTelemetry protos are up-to-date
|
||||
run: |
|
||||
./mlflow/protos/opentelemetry/update.sh
|
||||
GIT_STATUS="$(git status --porcelain mlflow/protos/opentelemetry/)"
|
||||
if [ "$GIT_STATUS" ]; then
|
||||
echo "OpenTelemetry proto files are outdated. Please run './mlflow/protos/opentelemetry/update.sh'"
|
||||
echo "Git status:"
|
||||
echo "$GIT_STATUS"
|
||||
exit 1
|
||||
fi
|
||||
- name: Run tests
|
||||
run: |
|
||||
./dev/test-generate-protos.sh
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
uv run python -c "from google.protobuf.internal import api_implementation; assert api_implementation.Type() != 'python'"
|
||||
uv run python -c "import mlflow"
|
||||
|
||||
# Ensure mlflow works fine with the python backend. See the following link for more details:
|
||||
# https://github.com/protocolbuffers/protobuf/tree/main/python#implementation-backends
|
||||
export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
|
||||
uv run python -c "from google.protobuf.internal import api_implementation; assert api_implementation.Type() == 'python'"
|
||||
uv run python -c "import mlflow"
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Push-Images
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- edited
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
push-images:
|
||||
# Only run for version tags (vX.Y.Z); skips non-version release tags like `model-catalog/latest`.
|
||||
if: "startsWith(github.ref_name, 'v') && !contains(github.ref_name, 'rc')"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
packages: write # to push to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if should update latest tag
|
||||
id: check_latest
|
||||
run: |
|
||||
CURRENT_VERSION=${GITHUB_REF_NAME#v}
|
||||
|
||||
HIGHEST_VERSION=$(gh release list --exclude-pre-releases --exclude-drafts --limit 100 | \
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+\s' | \
|
||||
awk '{print $1}' | \
|
||||
sed 's/^v//' | \
|
||||
sort -V | \
|
||||
tail -n1)
|
||||
|
||||
if [ -z "$HIGHEST_VERSION" ] || [ "$(echo -e "$CURRENT_VERSION\n$HIGHEST_VERSION" | sort -V | tail -n1)" = "$CURRENT_VERSION" ]; then
|
||||
echo "should_update_latest=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "should_update_latest=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Gather Docker Metadata for Tracking
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/mlflow/mlflow
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ steps.check_latest.outputs.should_update_latest == 'true' }}
|
||||
|
||||
- name: Gather Docker Metadata for Full Image
|
||||
id: meta-full
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/mlflow/mlflow
|
||||
tags: |
|
||||
type=ref,event=tag,suffix=-full
|
||||
type=raw,value=latest-full,enable=${{ steps.check_latest.outputs.should_update_latest == 'true' }}
|
||||
|
||||
- name: Build and Push Base Image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: docker
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Build and Push Base Full Image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: docker
|
||||
file: docker/Dockerfile.full
|
||||
push: true
|
||||
tags: ${{ steps.meta-full.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Gather Docker Metadata for Model Server
|
||||
id: modelmeta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/mlflow/model-server
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ steps.check_latest.outputs.should_update_latest == 'true' }}
|
||||
|
||||
- name: Build Model Server Image
|
||||
run: |
|
||||
uv pip install --system .
|
||||
mlflow models build-docker -n model-server:latest --mlflow-home `pwd`
|
||||
|
||||
- name: Push Model Server Image
|
||||
env:
|
||||
TAGS: ${{ steps.modelmeta.outputs.tags }}
|
||||
run: |
|
||||
set -x
|
||||
|
||||
tags=$(echo -n "$TAGS" | tr '\n' ' ')
|
||||
for tag in $tags; do
|
||||
docker tag model-server:latest $tag
|
||||
docker push $tag
|
||||
done
|
||||
@@ -0,0 +1,121 @@
|
||||
name: R
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
schedule:
|
||||
# Run this workflow daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
r:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: (github.event_name == 'push' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')))
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mlflow/R/mlflow
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || null }}
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- uses: r-lib/actions/setup-r@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2.12.0
|
||||
# This step dumps the current set of R dependencies and R version into files to be used
|
||||
# as a cache key when caching/restoring R dependencies.
|
||||
- name: Dump dependencies
|
||||
run: |
|
||||
Rscript -e 'source(".dump-r-dependencies.R", echo = TRUE)'
|
||||
- name: Get OS name
|
||||
id: os-name
|
||||
run: |
|
||||
# `os_name` will be like "Ubuntu-20.04.1-LTS"
|
||||
os_name=$(lsb_release -ds | sed 's/\s/-/g')
|
||||
echo "os-name=$os_name" >> $GITHUB_OUTPUT
|
||||
- name: Cache R packages
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
continue-on-error: true
|
||||
# https://github.com/actions/cache/issues/810
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MINS: 5
|
||||
with:
|
||||
path: ${{ env.R_LIBS_USER }}
|
||||
# We cache R dependencies based on a tuple of the current OS, the R version, and the list of
|
||||
# R dependencies
|
||||
key: ${{ steps.os-name.outputs.os-name }}-${{ hashFiles('mlflow/R/mlflow/R-version') }}-0-${{ hashFiles('mlflow/R/mlflow/depends.Rds') }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
Rscript -e 'source(".install-deps.R", echo=TRUE)'
|
||||
- name: Set USE_R_DEVEL
|
||||
env:
|
||||
HAS_R_DEVEL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'r-devel') }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "schedule" ]; then
|
||||
USE_R_DEVEL=true
|
||||
elif [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
# Use r-devel on a pull request targeted to a release branch or with the "r-devel" label
|
||||
if [[ $GITHUB_BASE_REF =~ branch-[0-9]+\.[0-9]+$ ]] || [ "$HAS_R_DEVEL_LABEL" = "true" ]; then
|
||||
USE_R_DEVEL=true
|
||||
else
|
||||
USE_R_DEVEL=false
|
||||
fi
|
||||
else
|
||||
# Use r-devel on a push to a release branch
|
||||
USE_R_DEVEL=$([[ $GITHUB_REF_NAME =~ branch-[0-9]+\.[0-9]+$ ]] && echo true || echo false)
|
||||
fi
|
||||
echo "USE_R_DEVEL=$USE_R_DEVEL" >> $GITHUB_ENV
|
||||
- name: Build package
|
||||
run: |
|
||||
./build-package.sh
|
||||
- name: Create test environment
|
||||
run: |
|
||||
uv pip install --system $(git rev-parse --show-toplevel)
|
||||
H2O_VERSION=$(Rscript -e "print(packageVersion('h2o'))" | grep -Eo '[0-9][0-9.]+')
|
||||
# test-keras-model.R fails with tensorflow>=2.13.0
|
||||
uv pip install --system xgboost 'tensorflow<2.13.0' "h2o==$H2O_VERSION" "pydantic<3,>=1.0" "typing_extensions>=4.6.0"
|
||||
Rscript -e 'source(".install-mlflow-r.R", echo=TRUE)'
|
||||
- name: Run tests
|
||||
env:
|
||||
LINTR_COMMENT_BOT: false
|
||||
# TODO: Migrate R tests to use a SQLite backend instead of FileStore
|
||||
MLFLOW_ALLOW_FILE_STORE: true
|
||||
run: |
|
||||
cd tests
|
||||
export RETICULATE_PYTHON_BIN=$(which python)
|
||||
export MLFLOW_SERVER_ENABLE_JOB_EXECUTION=false
|
||||
Rscript -e 'source("../.run-tests.R", echo=TRUE)'
|
||||
@@ -0,0 +1,129 @@
|
||||
async function fetchRepoLabels({ github, owner, repo }) {
|
||||
const { data } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100, // the default value is 30, which is too small to fetch all labels
|
||||
});
|
||||
return data.map(({ name }) => name);
|
||||
}
|
||||
|
||||
async function fetchPrLabels({ github, owner, repo, issue_number }) {
|
||||
const { data } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
return data.map(({ name }) => name);
|
||||
}
|
||||
|
||||
function isReleaseNoteLabel(name) {
|
||||
return name.startsWith("rn/");
|
||||
}
|
||||
|
||||
async function validateLabeled({ core, context, github }) {
|
||||
const { user, html_url: pr_url } = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const { number: issue_number } = context.issue;
|
||||
|
||||
// Skip validation on pull requests created by the automation bot
|
||||
if (user.login === "mlflow-app[bot]") {
|
||||
console.log("This pull request was created by the automation bot, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoLabels = await fetchRepoLabels({ github, owner, repo });
|
||||
const releaseNoteLabels = repoLabels.filter(isReleaseNoteLabel);
|
||||
|
||||
// Fetch the release-note category labels applied on this PR
|
||||
const fetchAppliedLabels = async () => {
|
||||
const backoffs = [0, 1, 2, 4, 8, 16];
|
||||
for (const [index, backoff] of backoffs.entries()) {
|
||||
console.log(`Attempt ${index + 1}/${backoffs.length}`);
|
||||
await new Promise((r) => setTimeout(r, backoff * 1000));
|
||||
const prLabels = await fetchPrLabels({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
const prReleaseNoteLabels = prLabels.filter((name) => releaseNoteLabels.includes(name));
|
||||
|
||||
if (prReleaseNoteLabels.length > 0) {
|
||||
return prReleaseNoteLabels;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const prReleaseNoteLabels = await fetchAppliedLabels();
|
||||
|
||||
// If no release note category label is applied to this PR, set the action status to "failed"
|
||||
if (prReleaseNoteLabels.length === 0) {
|
||||
// Make sure '.github/pull_request_template.md' contains an HTML anchor with this name
|
||||
const anchorName = "release-note-category";
|
||||
|
||||
// Fragmented URL to jump to the release note category section in the PR description
|
||||
const anchorUrl = `${pr_url}#user-content-${anchorName}`;
|
||||
const message = [
|
||||
"No release-note label is applied to this PR. ",
|
||||
`Please select a checkbox in the release note category section: ${anchorUrl} `,
|
||||
"or manually apply a release note category label (e.g. 'rn/bug-fix') ",
|
||||
"if you're a maintainer of this repository. ",
|
||||
"If this job failed when a release note category label is already applied, ",
|
||||
"please re-run it.",
|
||||
].join("");
|
||||
core.setFailed(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function postMerge({ context, github }) {
|
||||
const { user } = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const { number: issue_number } = context.issue;
|
||||
|
||||
if (user.login === "mlflow-app[bot]") {
|
||||
console.log("This PR was created by the automation bot, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoLabels = await fetchRepoLabels({ github, owner, repo });
|
||||
const releaseNoteLabels = repoLabels.filter(isReleaseNoteLabel);
|
||||
const prLabels = await fetchPrLabels({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
const prReleaseNoteLabels = prLabels.filter((name) => releaseNoteLabels.includes(name));
|
||||
|
||||
if (prReleaseNoteLabels.length === 0) {
|
||||
const pull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
});
|
||||
const { login: mergedBy } = pull.data.merged_by;
|
||||
const noneLabel = "rn/none";
|
||||
const body = [
|
||||
`@${mergedBy} This PR is missing a release-note label, adding \`${noneLabel}\`. `,
|
||||
"If this label is incorrect, please replace it with the correct label.",
|
||||
].join("");
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [noneLabel],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateLabeled,
|
||||
postMerge,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
# A workflow to validate at least one release-note category is selected
|
||||
|
||||
name: release-note
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
# post-merge job requires write access to add a label and post a comment.
|
||||
pull_request_target:
|
||||
types:
|
||||
- closed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
validate-labeled:
|
||||
if: (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot') && github.event.action != 'closed'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
pull-requests: read # validateLabeled looks at PR's labels
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: validate-labeled
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
// https://github.com/actions/github-script#run-a-separate-file
|
||||
const script = require("./.github/workflows/release-note.js");
|
||||
await script.validateLabeled({ core, context, github });
|
||||
|
||||
post-merge:
|
||||
if: github.event.action == 'closed' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write # postMerge labels PRs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: post-merge
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
// https://github.com/actions/github-script#run-a-separate-file
|
||||
const script = require("./.github/workflows/release-note.js");
|
||||
await script.postMerge({ context, github });
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Remove Experimental Decorators
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run on the 1st day of every month at 00:00 UTC
|
||||
- cron: "0 0 1 * *"
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/remove-experimental-decorators.yml
|
||||
- dev/remove_experimental_decorators.py
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
remove-decorators:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
# Only run on the main repository
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
if: github.event_name != 'pull_request'
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
|
||||
- name: Run remove_experimental_decorators script
|
||||
id: remove
|
||||
run: |
|
||||
python dev/remove_experimental_decorators.py
|
||||
|
||||
[[ -n $(git status --porcelain) ]] && has_changes=true || has_changes=false
|
||||
echo "has_changes=$has_changes" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create branch, commit and push changes
|
||||
if: steps.remove.outputs.has_changes == 'true' && github.event_name != 'pull_request'
|
||||
id: branch
|
||||
run: |
|
||||
git config user.name 'mlflow-app[bot]'
|
||||
git config user.email 'mlflow-app[bot]@users.noreply.github.com'
|
||||
|
||||
TIMESTAMP=$(date +%Y-%m-%d-%H-%M-%S)
|
||||
BRANCH_NAME="automated/remove-experimental-decorators-${TIMESTAMP}"
|
||||
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
git checkout -b $BRANCH_NAME
|
||||
|
||||
git add -A
|
||||
git commit -m "Remove expired experimental decorators
|
||||
|
||||
Generated by: ${RUN_URL}"
|
||||
git push origin $BRANCH_NAME
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.remove.outputs.has_changes == 'true' && github.event_name != 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
BRANCH_NAME: ${{ steps.branch.outputs.branch_name }}
|
||||
run: |
|
||||
PR_BODY=$(printf "Generated by [workflow run](${RUN_URL})\n\nTo skip auto-removal for an API, use \`@experimental(version=..., skip=True)\`.")
|
||||
PR_URL=$(gh pr create \
|
||||
--title "Remove expired experimental decorators" \
|
||||
--body "$PR_BODY" \
|
||||
--base master \
|
||||
--head "$BRANCH_NAME")
|
||||
gh pr edit "$PR_URL" --add-label "team-review"
|
||||
@@ -0,0 +1,36 @@
|
||||
# Cross version tests sometimes fail due to transient errors. This workflow reruns failed tests.
|
||||
name: rerun-cross-version-tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run this workflow daily at 19:00 UTC (6 hours after cross-version-tests.yml workflow)
|
||||
- cron: "0 19 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
set-matrix:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
if: github.repository == 'mlflow/dev'
|
||||
permissions:
|
||||
actions: write # to rerun workflows
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
run_id=$(gh run list --repo "$REPO" -w cross-version-tests.yml --event schedule \
|
||||
-L 1 --json databaseId,conclusion \
|
||||
--jq '.[] | select(.conclusion != "success") | .databaseId')
|
||||
if [ -n "$run_id" ]; then
|
||||
gh run rerun "$run_id" --repo "$REPO" --failed
|
||||
fi
|
||||
@@ -0,0 +1,59 @@
|
||||
# Triggered by rerun.yml on PR approval.
|
||||
name: rerun-workflow-run
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [rerun]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
rerun:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # to rerun github action workflows
|
||||
steps:
|
||||
- name: Rerun failed checks
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
gh run download "$RUN_ID" -n pr_number -D /tmp/pr
|
||||
pr_number=$(cat /tmp/pr/pr_number)
|
||||
|
||||
head_sha=$(gh pr view "$pr_number" --json headRefOid --jq .headRefOid)
|
||||
|
||||
# Rerun failed github-actions check runs on the PR head, excluding the
|
||||
# rerun workflow itself (to prevent recursion). Always rerun "protect";
|
||||
# otherwise only rerun jobs that took <= 60s (e.g. the approval check).
|
||||
run_ids=$(
|
||||
gh api --paginate "repos/$GH_REPO/commits/$head_sha/check-runs?per_page=100" \
|
||||
--jq '.check_runs[]
|
||||
| select(.app.slug == "github-actions")
|
||||
| select(.status == "completed")
|
||||
| select(.conclusion == "failure")
|
||||
| select((.name | ascii_downcase) != "rerun")
|
||||
| select(
|
||||
(.name | ascii_downcase) == "protect"
|
||||
or ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) <= 60
|
||||
)
|
||||
| .html_url
|
||||
| capture("/actions/runs/(?<id>[0-9]+)").id' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
for id in $run_ids; do
|
||||
echo "Rerunning https://github.com/$GH_REPO/actions/runs/$id"
|
||||
gh run rerun "$id" --failed --repo "$GH_REPO" || echo "Failed to rerun $id"
|
||||
done
|
||||
@@ -0,0 +1,43 @@
|
||||
# Triggers rerun-workflow-run.yml on PR approval.
|
||||
# See https://stackoverflow.com/questions/67247752/how-to-use-secret-in-pull-request-review-similar-to-pull-request-target for why we need this approach and how it works.
|
||||
name: rerun
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
if: >-
|
||||
github.event.review.state == 'approved' &&
|
||||
(
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) ||
|
||||
(github.event.review.user.type == 'Bot' && contains(fromJSON('["mlflow-app[bot]", "nailaopus[bot]"]'), github.event.review.user.login))
|
||||
)
|
||||
steps:
|
||||
- name: Upload PR number
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
mkdir -p /tmp/pr
|
||||
echo $PR_NUMBER > /tmp/pr/pr_number
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: pr_number
|
||||
path: /tmp/pr/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,366 @@
|
||||
name: review
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/review.yml"
|
||||
- ".claude/skills/pr-review/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
# Auth gate: only users with admin or maintain role on the repo can trigger
|
||||
# the review (plus the Copilot bot). For issue_comment, BOTH the PR author
|
||||
# and the commenter are checked.
|
||||
gate:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
# Cheap author_association prefilter so random users can't spawn workflow
|
||||
# runs. The gate step below does the authoritative admin/maintain role
|
||||
# check.
|
||||
if: >
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.draft == false &&
|
||||
(contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) ||
|
||||
(github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')))
|
||||
||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) ||
|
||||
(github.event.issue.user.login == 'Copilot' && github.event.issue.user.type == 'Bot')
|
||||
) &&
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
startsWith(github.event.comment.body, '/review') &&
|
||||
!startsWith(github.event.comment.body, '/review-'))
|
||||
steps:
|
||||
- name: Check user permission
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type || github.event.issue.user.type }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
check_user() {
|
||||
local user=$1
|
||||
local type=$2
|
||||
if [ "$user" = "Copilot" ] && [ "$type" = "Bot" ]; then
|
||||
echo "Allowing Copilot bot: $user"
|
||||
return 0
|
||||
fi
|
||||
local role
|
||||
role=$(gh api "repos/$REPO/collaborators/$user/permission" --jq .role_name) \
|
||||
|| { echo "Failed to fetch permission for $user"; exit 1; }
|
||||
case "$role" in
|
||||
admin|maintain)
|
||||
echo "User $user is allowed (admin or maintain role)"
|
||||
;;
|
||||
*)
|
||||
echo "User $user is not allowed (admin or maintain role required)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
check_user "$PR_AUTHOR" "$PR_AUTHOR_TYPE"
|
||||
if [ "$EVENT_NAME" = "issue_comment" ]; then
|
||||
check_user "$COMMENTER" "User"
|
||||
fi
|
||||
|
||||
review:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
# GITHUB_TOKEN is unused — all GitHub API calls go through the app tokens
|
||||
# minted below. Grant nothing so any future accidental use of GITHUB_TOKEN
|
||||
# fails closed.
|
||||
permissions: {}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# Two app tokens with split scopes:
|
||||
# - app-token-read (read-only): used by the Claude step so prompt-injection
|
||||
# in a diff can't trigger comments, approvals, or any PR mutation.
|
||||
# - app-token-write (write): used by React/Post/Report (code we control).
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token-read
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
permission-contents: read
|
||||
permission-pull-requests: read
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
if: github.event_name == 'issue_comment'
|
||||
id: app-token-write
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
permission-actions: read
|
||||
permission-contents: read
|
||||
permission-pull-requests: write
|
||||
- name: Resolve job URL
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# first(...) // empty: pick exactly one id, or output nothing if no match.
|
||||
JOB_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \
|
||||
--jq 'first(.jobs[] | select(.name == "review") | .id) // empty')
|
||||
# Fall back to the workflow-run URL so downstream links are never malformed.
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID/job/$JOB_ID?pr=$PR_NUMBER"
|
||||
else
|
||||
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID?pr=$PR_NUMBER"
|
||||
fi
|
||||
echo "JOB_RUN_URL=$JOB_RUN_URL" >> "$GITHUB_ENV"
|
||||
- name: React to comment
|
||||
if: github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token-write.outputs.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
const { comment } = context.payload;
|
||||
const message = `🚀 [Review running...](${process.env.JOB_RUN_URL})`;
|
||||
const updatedBody = `${comment.body}\n\n---\n\n${message}`;
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
body: updatedBody,
|
||||
});
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.app-token-read.outputs.token }}
|
||||
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/merge
|
||||
# Depth 2 so HEAD^1 (the merge ref's base parent) is reachable, letting
|
||||
# the pr-review skill read pre-change content via `git show HEAD^1:<path>`.
|
||||
fetch-depth: 2
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
pin-micro-version: false
|
||||
- name: Install Claude CLI
|
||||
run: |
|
||||
.claude/scripts/install-claude.sh
|
||||
- name: Verify Claude CLI
|
||||
run: |
|
||||
claude --version
|
||||
|
||||
- name: Parse review arguments
|
||||
id: prompt
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event_name == 'issue_comment' && github.event.comment.body || '' }}
|
||||
LABELS_JSON: ${{ toJson(github.event.pull_request.labels || github.event.issue.labels) }}
|
||||
run: |
|
||||
cat > /tmp/parse_args.py <<'EOF'
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODEL_CHOICES = [
|
||||
"fable",
|
||||
"opus",
|
||||
"sonnet",
|
||||
"haiku",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]
|
||||
EFFORT_CHOICES = ["low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
labels = {l["name"] for l in json.loads(os.environ["LABELS_JSON"])}
|
||||
big = bool(labels & {"size/M", "size/L", "size/XL"})
|
||||
default_model = "claude-opus-4-7" if big else "claude-sonnet-4-6"
|
||||
|
||||
body = sys.stdin.read().removeprefix("/review")
|
||||
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("-m", "--model", choices=MODEL_CHOICES, default=default_model)
|
||||
parser.add_argument("-e", "--effort", choices=EFFORT_CHOICES, default="high")
|
||||
args = parser.parse_args(body.split())
|
||||
|
||||
sys.stdout.write(f"model={args.model}\neffort={args.effort}\n")
|
||||
EOF
|
||||
|
||||
echo "$COMMENT_BODY" | python /tmp/parse_args.py >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
env:
|
||||
# Read-only token: Claude can fetch PR/diff/comments but cannot post anything.
|
||||
GH_TOKEN: ${{ steps.app-token-read.outputs.token }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
MODEL: ${{ steps.prompt.outputs.model }}
|
||||
EFFORT: ${{ steps.prompt.outputs.effort }}
|
||||
run: |
|
||||
OUTPUT_FILE="/tmp/output.jsonl"
|
||||
|
||||
# Both event paths run /pr-review end-to-end against the PR; on
|
||||
# pull_request (smoke), Post is gated off so no review is submitted.
|
||||
EXIT_CODE=0
|
||||
timeout 20m claude \
|
||||
--model "$MODEL" \
|
||||
--effort "$EFFORT" \
|
||||
--max-budget-usd 5 \
|
||||
--print \
|
||||
--verbose \
|
||||
--output-format stream-json \
|
||||
"/pr-review $REPO $PR_NUMBER" \
|
||||
| .claude/scripts/stream.sh "$OUTPUT_FILE" || EXIT_CODE=$?
|
||||
if [ "${EXIT_CODE:-0}" -eq 124 ]; then
|
||||
echo "Warning: Claude command timed out after 20 minutes"
|
||||
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
|
||||
echo "Error: Claude command failed with exit code $EXIT_CODE"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
- name: Validate review payload
|
||||
run: |
|
||||
if [ ! -f /tmp/review-payload.json ]; then
|
||||
echo "::error::Claude did not produce /tmp/review-payload.json"
|
||||
exit 1
|
||||
fi
|
||||
uv run --frozen --package skills skills validate-review /tmp/review-payload.json
|
||||
echo "::group::Review payload"
|
||||
jq . /tmp/review-payload.json
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Post review
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh api --method POST repos/"$REPO"/pulls/"$PR_NUMBER"/reviews --input /tmp/review-payload.json
|
||||
|
||||
- name: Redact secrets from transcript
|
||||
if: always()
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GH_TOKEN_READ: ${{ steps.app-token-read.outputs.token }}
|
||||
GH_TOKEN_WRITE: ${{ steps.app-token-write.outputs.token }}
|
||||
run: |
|
||||
python <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("/tmp/output.jsonl")
|
||||
if not path.exists():
|
||||
sys.exit(0)
|
||||
data = path.read_text()
|
||||
for name in ("ANTHROPIC_API_KEY", "GH_TOKEN_READ", "GH_TOKEN_WRITE"):
|
||||
if val := os.environ.get(name):
|
||||
data = data.replace(val, "***")
|
||||
path.write_text(data)
|
||||
PY
|
||||
|
||||
- name: Upload Claude stream transcript
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: claude-stream-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: /tmp/output.jsonl
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Report review results
|
||||
if: always() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
MODEL: ${{ steps.prompt.outputs.model }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token-write.outputs.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const { owner, repo } = context.repo;
|
||||
const { comment } = context.payload;
|
||||
const jobStatus = process.env.JOB_STATUS;
|
||||
const model = process.env.MODEL;
|
||||
|
||||
// Read Claude output from file instead of environment variable
|
||||
const outputPath = '/tmp/output.jsonl';
|
||||
let claudeOutput = '';
|
||||
try {
|
||||
if (fs.existsSync(outputPath)) {
|
||||
claudeOutput = fs.readFileSync(outputPath, 'utf8');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Failed to read Claude output file:', e);
|
||||
}
|
||||
|
||||
let resultEvent = null;
|
||||
if (claudeOutput) {
|
||||
try {
|
||||
const events = claudeOutput.trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
resultEvent = events.findLast(({ type }) => type === 'result');
|
||||
} catch (e) {
|
||||
console.log('Failed to parse Claude output as JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const jobRunUrl = process.env.JOB_RUN_URL;
|
||||
const failed = jobStatus !== 'success' || resultEvent?.is_error;
|
||||
const icon = failed ? '❌' : '✅';
|
||||
const verb = failed ? 'failed' : 'completed';
|
||||
let resultLine = `${icon} [Review](${jobRunUrl}) ${verb}`;
|
||||
|
||||
if (resultEvent) {
|
||||
const seconds = (resultEvent.duration_ms / 1000).toFixed(1);
|
||||
const tokens = (resultEvent.usage?.input_tokens ?? 0) + (resultEvent.usage?.output_tokens ?? 0);
|
||||
const cost = (Math.round(resultEvent.total_cost_usd * 100) / 100).toFixed(2);
|
||||
resultLine += ` (${model}, ${seconds}s, ${resultEvent.num_turns} turns, ${tokens} tokens, $${cost})`;
|
||||
const summary = resultEvent.is_error ? 'Error' : 'Result';
|
||||
const formatted = JSON.stringify(resultEvent, null, 2);
|
||||
resultLine += `\n<details><summary>${summary}</summary>\n\n\`\`\`json\n${formatted}\n\`\`\`\n\n</details>`;
|
||||
if (resultEvent.is_error && /529|Overloaded/i.test(resultEvent.result || '')) {
|
||||
resultLine += `\n\nThe Claude API is overloaded. Check [status.claude.com](https://status.claude.com/) and retry.`;
|
||||
}
|
||||
} else if (failed) {
|
||||
resultLine += `\n\nSee [workflow logs](${jobRunUrl}) for details.`;
|
||||
}
|
||||
|
||||
console.log('Result line to post:', resultLine);
|
||||
|
||||
const { data: currentComment } = await github.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
|
||||
const originalBody = currentComment.body.split('\n\n---\n\n🚀 ')[0];
|
||||
const updatedBody = `${originalBody}\n\n---\n\n${resultLine}`;
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
body: updatedBody
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
# A daily job to run slow tests with MLFLOW_RUN_SLOW_TESTS environment variable set to true.
|
||||
name: MLflow Slow Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
# Run this workflow in PR when relevant files change
|
||||
- ".github/workflows/slow-tests.yml"
|
||||
- "tests/docker/**"
|
||||
- "tests/pyfunc/docker/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# Run this workflow daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_RUN_SLOW_TESTS: "true"
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2, 3]
|
||||
include:
|
||||
- splits: 3
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra extras
|
||||
uv pip install \
|
||||
-r requirements/test-requirements.txt \
|
||||
-r requirements/extra-ml-requirements.txt \
|
||||
langchain_experimental "pyarrow<18"
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Run tests
|
||||
env:
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
uv run --no-sync pytest --splits=$SPLITS --group=$GROUP tests/docker tests/pyfunc/docker
|
||||
@@ -0,0 +1,205 @@
|
||||
const { readFileSync, existsSync, readdirSync, statSync } = require("fs");
|
||||
const { join, basename } = require("path");
|
||||
|
||||
// Constants
|
||||
const RELEASE_TAG = "nightly";
|
||||
const DAYS_TO_KEEP = 3;
|
||||
|
||||
/**
|
||||
* Check if artifact file type is supported
|
||||
*/
|
||||
function isSupportedArtifact(filename) {
|
||||
return /\.(whl|jar|tar\.gz)$/.test(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type based on file extension
|
||||
*/
|
||||
function getContentType(filename) {
|
||||
if (filename.match(/\.whl$/)) {
|
||||
return "application/zip";
|
||||
} else if (filename.match(/\.tar\.gz$/)) {
|
||||
return "application/gzip";
|
||||
} else if (filename.match(/\.jar$/)) {
|
||||
return "application/java-archive";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported file type for content type: ${filename}. Only .whl, .jar, and .tar.gz are supported.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset should be deleted based on age
|
||||
*/
|
||||
function shouldDeleteAsset(asset, daysToKeep) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
const assetDate = new Date(asset.created_at);
|
||||
return assetDate < cutoffDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add commit SHA to filename based on file type
|
||||
*/
|
||||
function addShaToFilename(filename, sha) {
|
||||
const shortSha = sha.substring(0, 7);
|
||||
|
||||
// Match .whl files
|
||||
if (filename.match(/\.whl$/)) {
|
||||
// For wheel files, insert SHA as build tag before .whl extension
|
||||
// Build tags must start with a digit, so prefix with "0"
|
||||
const wheelParts = filename.match(/^(.+?)(-py[^-]+(?:-[^-]+)*\.whl)$/);
|
||||
if (wheelParts) {
|
||||
return `${wheelParts[1]}-0${shortSha}${wheelParts[2]}`;
|
||||
}
|
||||
// Fallback for non-standard wheel names
|
||||
return filename.replace(/\.whl$/, `-0${shortSha}.whl`);
|
||||
}
|
||||
|
||||
// Match .jar files
|
||||
if (filename.match(/\.jar$/)) {
|
||||
return filename.replace(/\.jar$/, `-${shortSha}.jar`);
|
||||
}
|
||||
|
||||
// Match .tar.gz files
|
||||
if (filename.match(/\.tar\.gz$/)) {
|
||||
return filename.replace(/\.tar\.gz$/, `-${shortSha}.tar.gz`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unexpected file extension for: ${filename}. Only .whl, .jar, and .tar.gz are supported.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload artifacts to a GitHub release
|
||||
*/
|
||||
async function uploadSnapshots({ github, context, artifactDir }) {
|
||||
if (!existsSync(artifactDir)) {
|
||||
throw new Error(`Artifacts directory not found: ${artifactDir}`);
|
||||
}
|
||||
|
||||
const artifactFiles = readdirSync(artifactDir)
|
||||
.map((f) => join(artifactDir, f))
|
||||
.filter((f) => statSync(f).isFile());
|
||||
|
||||
if (artifactFiles.length === 0) {
|
||||
throw new Error(`No artifacts found in ${artifactDir}`);
|
||||
}
|
||||
|
||||
// Check for unsupported file types
|
||||
const unsupportedFiles = artifactFiles.filter((f) => !isSupportedArtifact(f));
|
||||
if (unsupportedFiles.length > 0) {
|
||||
const names = unsupportedFiles.map((f) => ` - ${basename(f)}`).join("\n");
|
||||
throw new Error(
|
||||
`Found unsupported file types:\n${names}\nOnly .whl, .jar, and .tar.gz files are supported.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the release already exists
|
||||
const { owner, repo } = context.repo;
|
||||
let release;
|
||||
let releaseExists = false;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: RELEASE_TAG,
|
||||
});
|
||||
release = data;
|
||||
releaseExists = true;
|
||||
console.log(`Found existing release: ${release.id}`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseParams = {
|
||||
owner,
|
||||
repo,
|
||||
tag_name: RELEASE_TAG,
|
||||
target_commitish: context.sha,
|
||||
name: `Nightly Build ${new Date().toISOString().split("T")[0]}`,
|
||||
body: `This is an automated nightly build of MLflow.
|
||||
|
||||
**Last updated:** ${new Date().toUTCString()}
|
||||
**Commit:** ${context.sha}
|
||||
|
||||
**Note:** This release is automatically updated daily with the latest changes from the master branch.`,
|
||||
prerelease: true,
|
||||
make_latest: "false",
|
||||
};
|
||||
|
||||
if (releaseExists) {
|
||||
console.log("Updating existing nightly release...");
|
||||
const { data: updatedRelease } = await github.rest.repos.updateRelease({
|
||||
...releaseParams,
|
||||
release_id: release.id,
|
||||
});
|
||||
release = updatedRelease;
|
||||
console.log(`Updated existing release: ${release.id}`);
|
||||
} else {
|
||||
console.log("Creating new nightly release...");
|
||||
const { data: newRelease } = await github.rest.repos.createRelease(releaseParams);
|
||||
release = newRelease;
|
||||
console.log(`Created new release: ${release.id}`);
|
||||
}
|
||||
|
||||
console.log("Fetching all existing assets...");
|
||||
const allAssets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
console.log(`Found ${allAssets.length} existing assets`);
|
||||
|
||||
// Delete old assets.
|
||||
for (const asset of allAssets) {
|
||||
if (shouldDeleteAsset(asset, DAYS_TO_KEEP)) {
|
||||
const assetDate = new Date(asset.created_at).toISOString().split("T")[0];
|
||||
console.log(`Deleting old asset (created ${assetDate}): ${asset.name}`);
|
||||
await github.rest.repos.deleteReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
asset_id: asset.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to get remaining assets after deletion
|
||||
const remainingAssets = allAssets.filter((asset) => !shouldDeleteAsset(asset, DAYS_TO_KEEP));
|
||||
|
||||
// Upload all artifacts
|
||||
for (const artifactPath of artifactFiles) {
|
||||
const artifactName = basename(artifactPath);
|
||||
const contentType = getContentType(artifactName);
|
||||
const nameWithSha = addShaToFilename(artifactName, context.sha);
|
||||
|
||||
// Check if artifact with SHA already exists in remaining assets
|
||||
if (remainingAssets.some((asset) => asset.name === nameWithSha)) {
|
||||
console.log(`Artifact already exists: ${nameWithSha} (skipping upload)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Uploading ${artifactName} as ${nameWithSha}...`);
|
||||
const artifactData = readFileSync(artifactPath);
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
name: nameWithSha,
|
||||
data: artifactData,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
"content-length": artifactData.length,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Successfully uploaded ${artifactName} as ${nameWithSha}`);
|
||||
}
|
||||
|
||||
console.log("All artifacts uploaded successfully");
|
||||
}
|
||||
|
||||
module.exports = { uploadSnapshots };
|
||||
@@ -0,0 +1,84 @@
|
||||
# Daily uploads package snapshots to https://github.com/mlflow/mlflow/releases/tag/nightly.
|
||||
name: snapshots
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # Runs daily at midnight UTC
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
pull_request: # To test updates
|
||||
paths:
|
||||
- ".github/workflows/snapshots.yml"
|
||||
- ".github/workflows/snapshots.js"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # Needed to create/update releases and manage assets
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-java
|
||||
- uses: r-lib/actions/setup-r@a51a8012b0aab7c32ef9d19bf54da93f3254335e # v2.12.0
|
||||
|
||||
- name: Create artifacts directory
|
||||
run: |
|
||||
ARTIFACT_DIR=$(mktemp -d)
|
||||
echo "ARTIFACT_DIR=$ARTIFACT_DIR" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Python packages
|
||||
id: build-dist
|
||||
run: |
|
||||
uv pip install --system build
|
||||
python dev/build.py
|
||||
find $PWD/dist -type f -name "*.whl" -exec cp {} $ARTIFACT_DIR/ \;
|
||||
python dev/build.py --package-type skinny
|
||||
find $PWD/dist -type f -name "*.whl" -exec cp {} $ARTIFACT_DIR/ \;
|
||||
ls $ARTIFACT_DIR
|
||||
|
||||
- name: Build R package
|
||||
working-directory: mlflow/R/mlflow
|
||||
run: |
|
||||
Rscript -e 'install.packages("devtools")'
|
||||
Rscript -e 'devtools::build(path = ".")'
|
||||
find $PWD -name "*.tar.gz" -type f -exec cp {} $ARTIFACT_DIR/ \;
|
||||
ls $ARTIFACT_DIR
|
||||
|
||||
- name: Build Java packages
|
||||
working-directory: mlflow/java
|
||||
run: |
|
||||
mvn -B -DskipTests clean package
|
||||
find $PWD -path "*/target/*.jar" \
|
||||
! -name "*sources.jar" \
|
||||
! -name "*javadoc.jar" \
|
||||
! -name "original-*.jar" \
|
||||
-exec cp {} $ARTIFACT_DIR/ \;
|
||||
ls $ARTIFACT_DIR
|
||||
|
||||
- name: Upload artifacts to nightly release
|
||||
# This step requires contents write permission, but pull requests filed from forks do not have this permission.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'mlflow/mlflow'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const { uploadSnapshots } = require('./.github/workflows/snapshots.js');
|
||||
await uploadSnapshots({
|
||||
github,
|
||||
context,
|
||||
artifactDir: process.env.ARTIFACT_DIR
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user