Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- [ ] This is a normal PR from a fork targeting upstream `main`.
- [ ] This is a `hotfix/*` PR from a fork targeting upstream `stable`.
- [ ] This is a maintainer release PR from `main` to `stable`.
- [ ] This is a maintainer post-hotfix back-merge from upstream `stable` to
- [ ] This is a maintainer stable-patch back-merge from upstream `stable` to
`main`.
- [ ] I selected `Motphys/MotrixLab` as the base repository and the correct base
branch in GitHub's compare-across-forks UI.
Expand Down
45 changes: 34 additions & 11 deletions .github/REPOSITORY_SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ single source of truth.
branch protection rules. Protect both long-lived branches.
- Require pull requests, at least one maintainer review, and dismissal of stale
approvals.
- Require the `Branch policy / validate`, `Tests / pytest`, `PR checks / prek`,
and `CodeQL / analyze` status checks.
- Require the `Branch policy / validate`, `Tests / pytest (fast)`,
`Tests / pytest (numba)`, `PR checks / prek`, and `CodeQL / analyze` status
checks.
- Require approval of all review conversations before merge.
- Require linear history or squash merging; disable merge commits for ordinary
contributor PRs.
Expand All @@ -63,22 +64,38 @@ single source of truth.
`hotfix/*` PR; if a ruleset bypass is unavoidable, record the incident and
immediately follow up with the required hotfix PR.

## Temporary release branch rulesets

- Add branch rulesets targeting `release/**` and `hotfix/*`.
- Block force-pushes and deletion for both patterns.
- Do not require pull requests on the temporary branches themselves. The release
App must push the validated patch bump to `hotfix/*`, and governance remains
at the `hotfix/* → stable` review; `release/**` is governed by its PR into
`main`.
- Restrict direct writes where the ruleset supports actor conditions, but keep
the release App installation allowed for the automated patch bump.
- Require generated `release/v* → main` version PRs to use squash or rebase so
the resulting `main` head remains the detectable `chore(release):` commit. If
that trigger is missed, maintainers can use the documented
`open_release_pr=true` manual fallback.

## Stable branch and releases

- Configure normal pull requests from forks to target `main`.
- Require a release pull request from `main` to `stable`.
- Allow `hotfix/*` pull requests to target `stable` directly.
- Allow `hotfix/*` stable patch pull requests to target `stable` directly.
- Allow only the upstream maintainer back-merge `stable` → `main` after a
hotfix release; reject `stable` → `main` PRs from forks.
- Require every hotfix to be merged back into `main` after release.
stable patch release; reject `stable` → `main` PRs from forks.
- Require every stable patch release to be merged back into `main` after
release.
- Restrict tag creation for `v*` to maintainers and create tags from `stable`.
- Configure the Docker/package publishing workflow to run only for reviewed
tags on `stable`.
- Keep PyPI publishing automated from reviewed `stable` release tags, but keep
Docker publishing as a manual dispatch from `stable`.
- Restrict who can create, update, or delete `v*` tags.
- Add a tag ruleset for `v*` that allows creation only by maintainers and
prevents deletion or updates.
- Remember that Git tags do not record their source branch; the Docker
workflow's ancestry check is a second guard, while the tag ruleset and
- Add a tag ruleset for `v*` that allows creation only by maintainers and the
release App, and prevents deletion or updates.
- Remember that Git tags do not record their source branch; the PyPI and Docker
workflow ancestry checks are second guards, while the tag ruleset and
maintainer permissions enforce the `stable`-only release process.

## Security and automation
Expand All @@ -104,6 +121,12 @@ single source of truth.
third-party asset audit is complete.
- Configure the package/container publishing secrets only in the main
repository, never in forks.
- Create a release GitHub App, install it only on this repository with Contents
read/write and Pull requests read/write, and store its client ID/private key as
`RELEASE_APP_CLIENT_ID` / `RELEASE_APP_PRIVATE_KEY`. The workflow exchanges
those credentials for short-lived installation tokens; generated PRs, tags,
and GitHub Releases therefore trigger the downstream workflows and required
checks normally.
- Check Git LFS bandwidth/storage usage before publishing large releases.

GitHub references: [about rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets),
Expand Down
67 changes: 67 additions & 0 deletions .github/scripts/validate_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright Motphys Technology Co., Ltd. 2025, 2026
# SPDX-License-Identifier: Apache-2.0

"""Validate a release target selected by the release workflow."""

from argparse import ArgumentParser
from typing import Literal

from packaging.version import InvalidVersion, Version

ReleaseKind = Literal["normal", "stable-patch"]


def parse_version(value: str, label: str) -> Version:
try:
return Version(value)
except InvalidVersion as error:
message = f"{label} version {value!r} is not a valid PEP 440 version"
raise ValueError(message) from error


def validate(current_value: str, target_value: str, release_kind: ReleaseKind) -> Version:
current = parse_version(current_value, "current")
target = parse_version(target_value, "target")

if current.local is not None or current.dev is not None:
message = f"current release version must not be a dev or local version: {current}"
raise ValueError(message)
if target.local is not None or target.dev is not None:
message = f"target release version must not be a dev or local version: {target}"
raise ValueError(message)
if target <= current:
message = f"target version {target} must be newer than current version {current}"
raise ValueError(message)

if release_kind == "stable-patch":
release = current.release
if len(release) != 3:
message = f"stable branch patch releases require a three-part current version: {current}"
raise ValueError(message)
expected = Version(f"{release[0]}.{release[1]}.{release[2] + 1}")
if target != expected or target.pre is not None or target.post is not None:
message = (
"stable branch patch releases must use the exact next final patch version: "
f"expected {expected}, got {target}"
)
raise ValueError(message)

return target


def main() -> None:
parser = ArgumentParser(description=__doc__)
parser.add_argument("--current", required=True)
parser.add_argument("--target", required=True)
parser.add_argument("--release-kind", choices=("normal", "stable-patch"), required=True)
arguments = parser.parse_args()

try:
target = validate(arguments.current, arguments.target, arguments.release_kind)
except ValueError as error:
parser.exit(2, f"error: {error}\n")
print(target)


if __name__ == "__main__":
main()
111 changes: 72 additions & 39 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,36 +89,27 @@ scheduled scan. Review results under the repository's **Security** tab.
### File: `branch-policy.yml`

Checks pull request metadata without checking out contributor code. Normal PRs
must target `main`; a post-hotfix back-merge may use upstream `stable → main`.
Only a release PR from the upstream `main` branch or a `hotfix/*` PR may target
`stable`. Add `Branch policy / validate` as a required status check on both
must target `main`; a post-patch back-merge may use upstream `stable → main`.
Only a release PR from the upstream `main` branch or a `hotfix/*` stable patch
PR may target `stable`. Add `Branch policy / validate` as a required status check on both
protected branches.

## Docker Image Build Workflow

### File: `docker-build.yml`

Automatically builds and pushes Docker images to Docker Hub when a new version
tag pointing to the `stable` release branch is pushed.
Manually builds and pushes Docker images to Docker Hub after a release.

#### Trigger Conditions

The workflow is triggered when you push a git tag that matches the pattern `v*`.
The job then verifies that the tagged commit is reachable from `stable`:

```bash
git switch stable
git pull --ff-only upstream stable
git tag -a v0.3.0 -m "Release v0.3.0"
git push upstream v0.3.0
```

Tags whose commit is not reachable from `stable` are rejected by the
verification step and are not published. Git tags do not retain the branch on
which they were created, so the tag ruleset and maintainer-only permissions
remain the authoritative control for requiring that tags are created from
The workflow is triggered only by manual dispatch. Run it from the `stable`
branch after the release workflow has created the matching `v*` tag and GitHub
Release. The job verifies that the dispatched commit is reachable from
`stable`.

Commits that are not reachable from `stable` are rejected by the verification
step and are not published.

#### What It Does

1. **Extracts Version**: Reads the version from `pyproject.toml` (currently `0.3.0`)
Expand All @@ -145,21 +136,10 @@ To add secrets:

#### Usage Example

```bash
# 1. Update version in pyproject.toml if needed
# 2. Commit your changes
git add .
git commit -m "Release v0.3.0"

# 3. From the upstream stable commit, create and push an annotated version tag
git switch stable
git pull --ff-only upstream stable
git tag -a v0.3.0 -m "Release v0.3.0"
git push upstream v0.3.0

# 4. The workflow will automatically build and push the Docker image
# 5. Monitor the build at: https://github.com/Motphys/MotrixLab/actions
```
1. Complete the release pipeline and wait for the GitHub Release.
2. Open **Actions → Build and Push Docker Image → Run workflow**.
3. Select the `stable` branch and run the workflow.
4. Monitor the run at <https://github.com/Motphys/MotrixLab/actions>.

#### Built Image Tags

Expand All @@ -182,7 +162,7 @@ docker pull motphys/motrixlab:v0.3
- ✅ **Multi-tag Support**: Automatically tags with version, major.minor, and latest
- ✅ **Version Extraction**: Automatically reads version from pyproject.toml
- ✅ **Docker Layer Caching**: Uses UV cache mounts for faster dependency installation
- ✅ **Tag-based Trigger**: Only builds on version tags, not on every commit
- ✅ **Manual Trigger**: Does not run automatically when a release tag is pushed

#### Docker Image Contents

Expand Down Expand Up @@ -217,14 +197,67 @@ docker run --gpus all motphys/motrixlab:test scripts/view.py env=cartpole
- Ensure `pyproject.toml` has a valid `version = "x.y.z"` line
- Check the workflow logs for the exact extraction command output

**Tag not triggering the workflow:**
**Workflow rejecting the dispatched commit:**

- Ensure the tag starts with `v` (e.g., `v0.3.0`, not `0.3.0`)
- Confirm that the tag points to a commit on `stable`, then push it from the
upstream repository (for example, `git push upstream v0.3.0`)
- Dispatch the workflow from the upstream `stable` branch after the release
pull request has been merged.
- Confirm that the selected commit is reachable from `stable`; the ancestry
check intentionally rejects manual runs from unreleased `main` commits.

#### See Also

- [Docker Hub Repository](https://hub.docker.com/r/motphys/motrixlab)
- [Container Deployment Documentation](../../docs/source/zh_CN/user_guide/getting_started/container_deployment.md)
- [Dockerfile](../../docker/Dockerfile)

# Python Package Publishing

### File: `pypi-publish.yml`

Publishes the `motrix-env-core`, `motrix-deploy`, and `motrix-rl` distributions
(the dependency closure of the public packages) to a Python package index.

- **Release trigger**: publishing a GitHub Release whose `v`-prefixed tag matches the
workspace package version uploads all three packages to pypi.org.
- **Manual trigger**: `workflow_dispatch` uploads to TestPyPI by default, or to
pypi.org with `index=pypi`. Use a TestPyPI run to verify the full install
closure before creating the production release. A manual formal-PyPI run has
the same stable ancestry, exact-tag, and workspace-version guards as a Release
event.

For production publication, the selected commit must be an ancestor of `stable`
and the matching immutable tag must point exactly at that commit. Every workspace
package must carry the same version before any distribution is built. A dedicated
build job uploads one wheel per package as a workflow artifact, and one publishing
job per package then uploads them in dependency order (env-core → deploy → rl).
Existing immutable distributions are skipped during recovery, so a partial
failure can be retried without replacing an already-published package.

Authentication uses PyPI Trusted Publishing (OIDC) — no API tokens are stored
in repository secrets. Before the first run, a maintainer must register a
pending publisher for each of the three project names on both pypi.org and
test.pypi.org, pointing at `Motphys/MotrixLab` with the workflow file
`pypi-publish.yml` and the matching job environment (`pypi-env-core`,
`pypi-deploy`, or `pypi-rl`). The distinct environments are required because
PyPI binds at most one project to a repository/workflow/environment
combination.

# Release Pipeline

### File: `release.yml`

Automates version bumps, release pull requests, stable tags, and GitHub
Releases while preserving human review at both governance points: the version
bump merge into `main` and the release merge into `stable`.

See the dedicated [Release Workflow Guide](RELEASE.md) for:

- one-time setup,
- normal release instructions,
- stable branch patch release instructions,
- version selection rules,
- release App setup,
- TestPyPI validation,
- package publishing order,
- automatic stable-patch back-merge,
- failure recovery.
Loading
Loading