chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:34 +08:00
commit 4b22cfda96
9037 changed files with 2363717 additions and 0 deletions
@@ -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
+1
View File
@@ -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)
![console-panel](https://github.com/mlflow/mlflow/blob/master/.github/ISSUE_TEMPLATE/images/console-panel.png?raw=true)
<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.
![network-panel](https://github.com/mlflow/mlflow/blob/master/.github/ISSUE_TEMPLATE/images/network-panel.png?raw=true)
<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}
```
+11
View File
@@ -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 &
+27
View File
@@ -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
+19
View File
@@ -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
+62
View File
@@ -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
+79
View File
@@ -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
+21
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
name: "untracked"
description: "Detect untracked files"
runs:
using: "node24"
main: "index.js"
post: "post.js"
+1
View File
@@ -0,0 +1 @@
// Does nothing
+20
View File
@@ -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).
+467
View File
@@ -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],
)
}
+98
View File
@@ -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
+56
View File
@@ -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.
+57
View File
@@ -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()
+10
View File
@@ -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__"
+190
View File
@@ -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(
"#### &#x274C; 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(
"#### &#x274C; 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(
"#### &#x274C; 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,
});
}
};
+35
View File
@@ -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 });
+109
View File
@@ -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);
}
};
+30
View File
@@ -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 });
+184
View File
@@ -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");
};
+40
View File
@@ -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 });
+230
View File
@@ -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 };
+37
View File
@@ -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
+217
View File
@@ -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,
};
+24
View File
@@ -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.
+324
View File
@@ -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);
+194
View File
@@ -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
+32
View File
@@ -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
+42
View File
@@ -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
+37
View File
@@ -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"],
});
}
};
+35
View File
@@ -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 });
+58
View File
@@ -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"
+34
View File
@@ -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"
+196
View File
@@ -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)
+218
View File
@@ -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
+66
View File
@@ -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
+181
View File
@@ -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
+177
View File
@@ -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;
}
};
+33
View File
@@ -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 });
+136
View File
@@ -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
+88
View File
@@ -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
+112
View File
@@ -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
+46
View File
@@ -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"
+113
View File
@@ -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
+46
View File
@@ -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"
+105
View File
@@ -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
+81
View File
@@ -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);
}
}
}
};
+38
View File
@@ -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 });
+41
View File
@@ -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
+149
View File
@@ -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
+467
View File
@@ -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
+18
View File
@@ -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
}
]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"problemMatcher": [
{
"owner": "black",
"severity": "error",
"pattern": [
{
"regexp": "^([^:]+):(\\d+):(\\d+): (Unformatted file\\. .+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
}
]
}
+18
View File
@@ -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
}
]
}
]
}
+18
View File
@@ -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
}
]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"problemMatcher": [
{
"owner": "typos",
"severity": "error",
"pattern": [
{
"regexp": "^(.+?):(\\d+):(\\d+):\\s*(.+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
]
}
]
}
+228
View File
@@ -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
+133
View File
@@ -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],
});
};
+35
View File
@@ -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 });
+136
View File
@@ -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],
});
}
};
+38
View File
@@ -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 });
+285
View File
@@ -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 });
};
+123
View File
@@ -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"
+192
View File
@@ -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");
};
+46
View File
@@ -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 });
+98
View File
@@ -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
+73
View File
@@ -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"
+129
View File
@@ -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
+121
View File
@@ -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)'
+129
View File
@@ -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,
};
+69
View File
@@ -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
+59
View File
@@ -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
+43
View File
@@ -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
+366
View File
@@ -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
});
+81
View File
@@ -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
+205
View File
@@ -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 };
+84
View File
@@ -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