Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Reviewers auto-requested on PRs. Add people per area as the team grows.
* @geng-haoran
/metasim/sim/ @geng-haoran
/metasim/scenario/ @geng-haoran
/metasim/task/ @geng-haoran
/.github/ @geng-haoran
33 changes: 33 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: changelog

# Every PR that changes the library must add its line under "## [Unreleased]" in CHANGELOG.md,
# unless it carries the `no-changelog` label (tests-only, refactors with no user-visible change).

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]

jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require a CHANGELOG entry for library changes
env:
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [[ ",$LABELS," == *",no-changelog,"* ]]; then echo "no-changelog label present"; exit 0; fi
changed=$(git diff --name-only "$BASE" "$HEAD")
if ! grep -qP '^metasim/(?!test/)' <<<"$changed"; then
echo "no library files changed"; exit 0
fi
if grep -qx 'CHANGELOG.md' <<<"$changed" && git diff "$BASE" "$HEAD" -- CHANGELOG.md | grep -q '^+.*- '; then
echo "CHANGELOG entry found"; exit 0
fi
echo "::error::This PR changes metasim/ but adds no line under '## [Unreleased]' in CHANGELOG.md (or add the no-changelog label)."
exit 1
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

# Pull-request gate that needs no simulator or GPU: lint at the pre-commit pin and the
# simulator-free test suite (`-k general`) on the supported CPython versions. Simulator-backed
# suites still run in the merge-queue workflow (premerge-ci.yml) on the GPU runner.

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install "ruff==0.14.5" # keep equal to .pre-commit-config.yaml
- run: python -m ruff check --output-format=github .
- run: python -m ruff format --check .

general:
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
name: general (${{ matrix.python-version }})
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install (CPU torch first so pip cannot pick a CUDA wheel)
run: |
python -m pip install --upgrade pip
python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
python -m pip install -e ".[dev,examples,mujoco]" # examples: rootutils/tyro used by test modules; mujoco: module-level imports in general tests
- name: Simulator-free test suite
env:
MUJOCO_GL: disable
run: python -m pytest -k "general and not falls_back_to_private" -q -rs
31 changes: 31 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: pr-title

# The squash-merge commit takes the PR title, so the title must be a Conventional Commit.

on:
pull_request:
types: [opened, edited, synchronize, reopened]

jobs:
pr-title:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
style
refactor
test
chore
ci
perf
requireScope: false
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: 'Start the summary in lower case, e.g. "fix(mujoco): reserve a 512M arena".'
79 changes: 79 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: release

# Tag vX.Y.Z on main -> verify version, build, GitHub Release, PyPI (trusted publishing, when the
# `pypi` environment exists). See RELEASING.md.

on:
push:
tags: ["v*"]

permissions:
contents: write

jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Tag must equal pyproject version
id: ver
run: |
python -m pip install tomli
pv=$(python -c "import tomli; print(tomli.load(open('pyproject.toml','rb'))['project']['version'])")
tag="${GITHUB_REF_NAME#v}"
if [[ "$pv" != "$tag" ]]; then echo "::error::tag v$tag != pyproject version $pv"; exit 1; fi
echo "version=$pv" >> "$GITHUB_OUTPUT"
- run: python -m pip install build twine
- run: python -m build
- run: python -m twine check dist/*
- name: Extract this version's CHANGELOG section
run: |
python - <<'PY'
import re, os
v = os.environ["VERSION"]; text = open("CHANGELOG.md", encoding="utf-8").read()
m = re.search(rf"^## \[{re.escape(v)}\][^\n]*\n(.*?)(?=^## \[|\Z)", text, re.S | re.M)
body = m.group(1).strip() if m else f"See CHANGELOG.md for {v}."
open("release_notes.md", "w", encoding="utf-8").write(body + "\n")
PY
env:
VERSION: ${{ steps.ver.outputs.version }}
- uses: actions/upload-artifact@v4
with:
name: dist
path: |
dist/*
release_notes.md

github-release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'a') || contains(github.ref_name, 'b') }}
files: dist/*.whl dist/*.tar.gz

pypi:
# Runs only after the `pypi` environment with a trusted publisher exists (RELEASING.md §5);
# without it the job fails on the OIDC exchange and the GitHub Release above still stands.
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist
- run: rm -f dist/release_notes.md
- uses: pypa/gh-action-pypi-publish@release/v1
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Nothing yet — open a PR to add entries here.
### Changed

- **Distribution renamed to `roboverse-metasim`** (`metasim` on PyPI is an unrelated project); the
import name stays `metasim`. Downstream requirements must say `roboverse-metasim @ git+...`.
`metasim.__version__` now reads the installed version from package metadata.

### Added

- `RELEASING.md` + `CONTRIBUTING.md`: branching, PR gates, SemVer, release checklist, PyPI
trusted publishing, branch-protection settings.
- CI on every PR without a GPU: `ci.yml` (ruff + `-k general` on 3.10/3.11), `pr-title.yml`
(Conventional Commit titles), `changelog.yml` (entry required for library changes),
`release.yml` (tag → build, GitHub Release, PyPI), `CODEOWNERS`.

## [0.2.0] - 2026-05-31

Expand Down
22 changes: 22 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Contributing to MetaSim

Thanks for helping. Two documents define how work lands here:

- [`AGENTS.md`](./AGENTS.md) — engineering rules (repo boundaries, parity, code style, testing).
- [`RELEASING.md`](./RELEASING.md) — branches, pull-request gates, versioning and the release
checklist.

Quick start:

```bash
git clone https://github.com/RoboVerseOrg/MetaSim.git && cd MetaSim
python -m pip install -e ".[dev,mujoco]" # or the simulator extra you work on, see docs/source/get_started/installation.rst
pre-commit install # ruff lint + format at the pinned version
python -m pytest -k general # no simulator needed
```

Open a PR from a `type/scope-slug` branch with a Conventional Commit title, add a line under
`## [Unreleased]` in `CHANGELOG.md` for anything a user can notice, and keep one concern per PR.

By contributing you agree that your contribution is licensed under the Apache License 2.0
(see `LICENSE`).
106 changes: 106 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Development and release protocol

This is the contract for how code reaches `main` and how `main` reaches users, for **MetaSim**
(this repo, PyPI distribution `roboverse-metasim`, import name `metasim`) and its downstream
**RoboVerse** (`roboverse-py`). The same protocol lives in RoboVerse's `RELEASING.md`; keep both
in sync.

## 1. Branches

- `main` is always releasable: every commit on it passed the required checks below.
- Work happens on short-lived branches named `<type>/<scope>-<slug>`, e.g. `feat/superdex-backend`,
`fix/hf-util-lock`, `maint/lint`. Types are the Conventional Commits types
(`feat|fix|docs|style|refactor|test|chore|ci|perf`).
- No long-lived `develop`. Backports go to `release/vX.Y` branches created from the tag only when a
patch release is needed.
- Branch protection on `main` (configured through the GitHub API, see §6): pull requests only, no
force pushes, no deletion, required status checks must pass, conversations must be resolved.

## 2. Pull requests

- One concern per PR. Title is a Conventional Commit (`type(scope): summary`); the `pr-title`
check enforces it because the squash-merge commit takes the PR title.
- Required checks: `lint` (ruff check + format at the pre-commit pin), `general` (the
simulator-free test suite on Python 3.10 and 3.11), `pr-title`, and `changelog` (every PR that
touches `metasim/` adds a line under `## [Unreleased]` in `CHANGELOG.md`, or carries the
`no-changelog` label for pure refactors/tests).
- Simulator-backed suites (`-k mujoco|sapien3|isaacsim|isaacgym|newton|superdex`) run in the
merge-queue workflow on the GPU runner (`premerge-ci.yml`) and locally before a release (§4).
- Every behaviour change ships with a test; every public API change ships with docs and a
`CHANGELOG.md` entry that says what a user must do (migration line) if anything breaks.
- Reviews: at least one maintainer review before merge once a second maintainer is active
(`required_approving_review_count` in §6 is 0 today for a single-maintainer repo; raise it then).
- Merge method: **squash** (linear history, one commit per PR, commit message = PR title + body).

## 3. Versioning

- Semantic Versioning. `MAJOR.MINOR.PATCH`; pre-releases `X.Y.ZrcN`.
- PATCH: fixes only, no API change.
- MINOR: new backends, new config fields, new queries; existing call sites keep working.
- MAJOR: a documented breaking change to `BaseSimHandler`, the scenario cfg types, or the task
registry contract.
- The version is stored **once**, in `pyproject.toml` (`[project] version`); `metasim.__version__`
reads it from package metadata. Never hand-edit a version elsewhere.
- Tags are `vX.Y.Z` on `main` and are immutable. A tag that turns out broken gets a new patch
release, never a moved tag.
- Downstream pinning: RoboVerse depends on `roboverse-metasim @ git+https://github.com/RoboVerseOrg/MetaSim.git@vX.Y.Z`
(a tag, not `main`) and, once PyPI publishing is enabled, on `roboverse-metasim>=X.Y,<X.Y+1`.
Bumping that pin is a RoboVerse PR that runs its full suite against the new MetaSim.

## 4. Release checklist (MetaSim)

1. `main` is green and the merge queue is empty.
2. Run the simulator-backed suites in the environments of `ENVIRONMENTS.md`:
`pytest -k mujoco`, `-k sapien3`, `-k isaacsim`, `-k isaacgym`, `-k newton`, `-k superdex`.
Record the pass/skip/xfail counts in the release notes; xfails must each name a tracked reason.
3. Open a `chore(release): vX.Y.Z` PR that
- bumps `[project] version` in `pyproject.toml`,
- renames `## [Unreleased]` in `CHANGELOG.md` to `## [X.Y.Z] - YYYY-MM-DD` and adds a fresh
empty `## [Unreleased]`,
- updates `RELEASE_NOTES_NEXT.md` if it is used for the GitHub release text.
4. Merge it, then tag the squash commit: `git tag -a vX.Y.Z -m "MetaSim vX.Y.Z" && git push origin vX.Y.Z`.
5. `release.yml` runs on the tag: it verifies the tag equals the `pyproject.toml` version, builds
the sdist and wheel, checks them with `twine`, creates the GitHub Release with the matching
`CHANGELOG.md` section as body, and (when the `pypi` environment is configured, §5) publishes
to PyPI through trusted publishing.
6. Open the RoboVerse pin-bump PR (§3) the same day. RoboVerse's own release follows.
7. Announce in Discussions / Discord with the changelog link.

Patch releases: branch `release/vX.Y` from the tag, cherry-pick fixes, repeat steps 3-5 with
`base = release/vX.Y`.

## 5. Publishing to PyPI (one-time setup)

- The distribution name is **`roboverse-metasim`** — `metasim` on PyPI belongs to an unrelated
project. The import name stays `metasim`.
- Create the project on PyPI (upload the first version manually or reserve the name), then add a
*trusted publisher*: owner `RoboVerseOrg`, repository `MetaSim`, workflow `release.yml`,
environment `pypi`.
- In the GitHub repo create the `pypi` environment (Settings → Environments), restricted to tags
`v*`, with required reviewers = the release managers. Until this exists the publish job is
skipped and the GitHub Release still happens.

## 6. Repository settings (applied through `gh api`; re-apply after any change)

```bash
gh api -X PUT repos/RoboVerseOrg/MetaSim/branches/main/protection --input - <<'JSON'
{
"required_status_checks": {"strict": true, "contexts": ["lint", "general (3.10)", "general (3.11)", "pr-title", "changelog"]},
"enforce_admins": false,
"required_pull_request_reviews": {"required_approving_review_count": 0, "dismiss_stale_reviews": true},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_linear_history": true,
"required_conversation_resolution": true
}
JSON
gh api -X PATCH repos/RoboVerseOrg/MetaSim -f allow_squash_merge=true -f allow_merge_commit=false -f allow_rebase_merge=false -f delete_branch_on_merge=true -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=PR_BODY
```

## 7. Cross-repo rule

MetaSim owns the simulator contract, config types and the registry; RoboVerse owns content,
learning code and examples (see `AGENTS.md`). A feature that spans both lands in MetaSim first,
gets released (or at least tagged as a pre-release), and only then does the RoboVerse PR bump the
pin and use it. RoboVerse `main` must never depend on MetaSim `main`.
8 changes: 8 additions & 0 deletions metasim/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

from __future__ import annotations

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _dist_version

try:
__version__ = _dist_version("roboverse-metasim")
except PackageNotFoundError: # running from a checkout that was never installed
__version__ = "0.0.0+unknown"


def register_gym_envs() -> None:
"""Discover task modules and register ``RoboVerse/<task>`` with Gymnasium.
Expand Down
2 changes: 1 addition & 1 deletion metasim/test/test_contract_api_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def test_pyproject_builds_metasim_distribution_only():
assert pyproject["build-system"]["build-backend"] == "setuptools.build_meta"

project = pyproject["project"]
assert project["name"] == "metasim"
assert project["name"] == "roboverse-metasim" # PyPI name; `metasim` is an unrelated project there
assert project["description"] == "MetaSim: A unified simulation framework for robotics"
assert project["license"] == {"file": "LICENSE"}
assert project["requires-python"] == ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "metasim"
name = "roboverse-metasim" # PyPI name; the import name stays `metasim` (`metasim` on PyPI is an unrelated project)
version = "0.2.0"
description = "MetaSim: A unified simulation framework for robotics"
requires-python = ">=3.8"
Expand Down
Loading