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
4 changes: 2 additions & 2 deletions .config/mise/tasks/build
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
set -euo pipefail

echo "Building morphir package..."
uv build packages/morphir
uv build packages/morphir --out-dir packages/morphir/dist

echo "Building morphir-tools package..."
uv build packages/morphir-tools
uv build packages/morphir-tools --out-dir packages/morphir-tools/dist

echo "Build complete!"
5 changes: 1 addition & 4 deletions .config/mise/tasks/install
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
set -euo pipefail

echo "Syncing dependencies..."
uv sync --all-groups

echo "Installing workspace packages in editable mode..."
uv pip install -e packages/morphir -e packages/morphir-tools
uv sync --frozen --all-packages --all-groups

echo "Install complete!"
94 changes: 94 additions & 0 deletions .config/mise/tasks/release-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#MISE description="Check built distributions and scan runtime dependencies"
set -euo pipefail

# Keep the candidate environment separate from development and scanner dependencies.
release_dir="$(pwd)/.dev/release-check"
mkdir -p "$release_dir"

uv lock --check
python - <<'PY'
import os
import tarfile
import tomllib
import zipfile
from email.parser import BytesParser
from pathlib import Path

versions = set()
for name in ("morphir", "morphir-tools"):
directory = Path("packages") / name
project = tomllib.loads((directory / "pyproject.toml").read_text())["project"]
versions.add(project["version"])
tag = os.environ.get("RELEASE_TAG", "")
if tag and tag != f"v{project['version']}":
raise SystemExit(f"{tag} does not match {name} {project['version']}")
stem = name.replace("-", "_") + "-" + project["version"]
expected = {f"{stem}-py3-none-any.whl", f"{stem}.tar.gz"}
files = {p.name for p in (directory / "dist").iterdir()
if p.is_file() and p.name != ".gitignore"}
if files != expected:
raise SystemExit(f"Unexpected distributions for {name}: {files}")
for filename in expected:
path = directory / "dist" / filename
if path.suffix == ".whl":
with zipfile.ZipFile(path) as archive:
metadata = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")]
raw = archive.read(metadata[0])
has_license = any(n.endswith("/licenses/LICENSE") for n in archive.namelist())
else:
with tarfile.open(path) as archive:
member = archive.extractfile(f"{stem}/PKG-INFO")
if member is None:
raise SystemExit(f"Missing metadata in {path}")
raw = member.read()
has_license = f"{stem}/LICENSE" in archive.getnames()
metadata = BytesParser().parsebytes(raw)
if metadata["Name"] != name or metadata["Version"] != project["version"]:
raise SystemExit(f"Incorrect name or version in {path}")
if metadata["License-Expression"] != "Apache-2.0" or not has_license:
raise SystemExit(f"Missing Apache-2.0 license in {path}")
if len(versions) != 1:
raise SystemExit("Both packages must use the same release version")
PY

uvx --from twine==7.0.0 twine check --strict \
packages/morphir/dist/* packages/morphir-tools/dist/*

uv export --frozen --all-packages --no-default-groups --no-emit-workspace \
--no-hashes --output-file "$release_dir/constraints.txt"
uv venv --clear --python 3.14 "$release_dir/venv"
release_python="$release_dir/venv/bin/python"
uv pip install --python "$release_python" --constraint "$release_dir/constraints.txt" \
packages/morphir/dist/*.whl packages/morphir-tools/dist/*.whl
uv pip check --python "$release_python"

"$release_python" -I - <<'PY'
from importlib.metadata import version

import morphir
import morphir_tools
from morphir.ir import format_version
from morphir.sdk import basics

assert morphir.__version__ == version("morphir")
assert morphir_tools.__version__ == version("morphir-tools")
assert basics.round(2.5) == 3
assert format_version.V3.major == 3
PY
"$release_dir/venv/bin/morphir-tools" --help

# These two first-party packages are not yet on PyPI. Audit the exact installed
# third-party versions, without querying PyPI for the unpublished packages.
uv pip freeze --python "$release_python" --exclude morphir --exclude morphir-tools \
> "$release_dir/requirements.txt"
uvx --from pip-audit==2.10.1 pip-audit --strict --no-deps --disable-pip \
--requirement "$release_dir/requirements.txt" \
--format json --output "$release_dir/vulnerabilities.json"

# An exact allowlist rejects unknown licenses and newly introduced copyleft or
# proprietary licenses. Changes to this policy require maintainer review.
uvx --from pip-licenses==5.5.0 pip-licenses --python "$release_python" \
--from=mixed \
--allow-only='Apache-2.0;Apache Software License;MIT;MIT License;BSD-2-Clause;BSD-3-Clause;BSD License;ISC;ISC License;ISC License (ISCL)' \
--format=json --output-file "$release_dir/licenses.json"
35 changes: 30 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_call:
inputs:
release-tag:
description: Tag that must match both package versions
type: string
default: ''

permissions:
contents: read
Expand Down Expand Up @@ -86,10 +92,29 @@ jobs:
- name: Build packages
run: mise run build

- name: Upload artifacts
- name: Check distributions, vulnerabilities and licenses
run: mise run release-check
env:
RELEASE_TAG: ${{ inputs.release-tag }}

- name: Upload morphir distributions
uses: actions/upload-artifact@v6
with:
name: morphir-dist
path: packages/morphir/dist/
if-no-files-found: error

- name: Upload morphir-tools distributions
uses: actions/upload-artifact@v6
with:
name: morphir-tools-dist
path: packages/morphir-tools/dist/
if-no-files-found: error

- name: Upload release scan reports
if: always()
uses: actions/upload-artifact@v6
with:
name: dist
path: |
packages/morphir/dist/
packages/morphir-tools/dist/
name: release-scan-reports
path: .dev/release-check/*.json
include-hidden-files: true
55 changes: 22 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,30 @@ on:
push:
tags:
- 'v*'
# Qualification only. Publishing requires a tag push.
workflow_dispatch:

permissions:
contents: write
id-token: write
contents: read

jobs:
build:
name: Build packages
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

- name: Build morphir package
run: uv build packages/morphir
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

- name: Build morphir-tools package
run: uv build packages/morphir-tools

- name: Upload morphir dist
uses: actions/upload-artifact@v6
with:
name: morphir-dist
path: packages/morphir/dist/

- name: Upload morphir-tools dist
uses: actions/upload-artifact@v6
with:
name: morphir-tools-dist
path: packages/morphir-tools/dist/
jobs:
qualify:
name: Qualify release
uses: ./.github/workflows/ci.yml
with:
release-tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }}

publish-morphir:
name: Publish morphir to PyPI
needs: build
if: github.event_name == 'push' && github.repository == 'finos/morphir-python'
needs: qualify
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/project/morphir/
Expand All @@ -59,8 +43,10 @@ jobs:

publish-morphir-tools:
name: Publish morphir-tools to PyPI
needs: [build, publish-morphir]
needs: [qualify, publish-morphir]
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/project/morphir-tools/
Expand All @@ -78,13 +64,16 @@ jobs:
name: Create GitHub Release
needs: [publish-morphir, publish-morphir-tools]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Download all artifacts
uses: actions/download-artifact@v7
with:
pattern: '*-dist'
path: dist/
merge-multiple: true

Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,63 @@ mise run check
| `mise run coverage` | Run tests with coverage |
| `mise run check` | Run all checks |
| `mise run build` | Build packages |
| `mise run release-check` | Validate built packages and scan runtime dependencies for vulnerabilities and licenses |
| `mise run clean` | Clean build artifacts |

## Releases

The initial release is `0.0.1` for both PyPI distributions:

| PyPI distribution | Python import | Command |
|-------------------|---------------|---------|
| `morphir` | `morphir` | None |
| `morphir-tools` | `morphir_tools` | `morphir-tools` |

`morphir-python` is the repository name. The workspace itself is not published.
Both packages require Python 3.14 or later. `morphir-tools` installs `morphir` as
a dependency.

Publishing uses [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/),
without a stored PyPI token. Configure a publisher for **each** distribution on
PyPI, including a pending publisher if the project has not been created yet:

| Publisher field | Value |
|-----------------|-------|
| Owner | `finos` |
| Repository | `morphir-python` |
| Workflow filename | `release.yml` |
| Environment | `pypi` |

The GitHub `pypi` environment permits release tags matching `v*`. After maintainer
approval, push a tag such as `v0.0.1` whose version matches both package metadata
files and their runtime `__version__` values. Update the tools package's minimum
`morphir` dependency and refresh `uv.lock` when changing versions.

The release workflow runs the same lint, format, type, coverage and BDD checks as
PR CI. It builds each package once, validates wheel and source metadata and license
files, installs the wheels in an isolated environment, checks imports and the CLI,
then scans the locked runtime dependency versions with `pip-audit` and
`pip-licenses`. Known vulnerabilities, audit failures, unknown licenses and
licenses outside the exact Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause and ISC
allowlist block publication. The license allowlist also accepts the corresponding
legacy license classifiers. The CVE scan queries third-party dependencies;
the unpublished first-party packages are excluded from that query. These checks
use package metadata and known vulnerability advisories, not a source-code license
or security review. Scan reports are retained as workflow artifacts.

Only the two publishing jobs receive `id-token: write`. They publish the checked
artifacts, core library first, and then create a GitHub release. The workflow's
manual **Run workflow** action runs qualification without publishing. To qualify
locally:

```bash
mise run install
mise run check
mise run coverage
mise run build
RELEASE_TAG=v0.0.1 mise run release-check
```

## Project Principles

This project follows functional programming principles:
Expand Down
Loading
Loading