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
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: CI

on:
push:
branches: [main, "release/**", "fix/**"]
pull_request:
branches: [main]
workflow_dispatch:
workflow_call:

permissions:
contents: read

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

env:
OMP_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.10.9"
- run: uv python install 3.14
- run: uv sync --python 3.14 --all-extras --locked
- run: uv run ruff check --isolated --select E4,E7,E9,F src tests

qualify:
name: Qualify (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.10.9"
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --python python --all-extras --locked
- name: Import package and data extra
run: >-
uv run --no-sync python -c
"import ml4t_coursework, yfinance; from ml4t_coursework import data;
assert data.fingerprint().shape == (100, 6)"
- run: uv run --no-sync pytest -q
- run: uv build --python python
- name: Install built wheel with data extra
shell: bash
run: |
uv venv --python python .artifact-venv
wheel="$(find dist -name '*.whl' -print -quit)"
uv pip install --python .artifact-venv "${wheel}[data]"
uv run --python .artifact-venv --no-project python -c \
"import ml4t_coursework, yfinance; from ml4t_coursework import data; assert data.fingerprint().shape == (100, 6)"

build:
name: Build Package
runs-on: ubuntu-latest
needs: [lint, qualify]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.10.9"
- run: uv python install 3.12
- run: uv sync --python 3.12 --all-extras --locked
- run: uv build --python 3.12
- run: uv run twine check dist/*
- name: Validate release version
if: startsWith(github.ref, 'refs/tags/')
run: >-
uv run python -c
"import os; from ml4t_coursework import __version__;
tag=os.environ['GITHUB_REF_NAME'];
assert tag == f'v{__version__}', f'{tag} does not match v{__version__}'"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Release

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

jobs:
qualification:
name: Qualify Release
permissions:
contents: read
uses: ./.github/workflows/ci.yml

publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: qualification
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: publish
permissions:
contents: write
steps:
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release create "${GITHUB_REF_NAME}" --generate-notes --verify-tag
135 changes: 31 additions & 104 deletions publish.sh
Original file line number Diff line number Diff line change
@@ -1,134 +1,61 @@
#!/usr/bin/env bash
# Upload ml4t-coursework to PyPI. Run by Stefan, not by an agent: the token is his and the
# first upload of a name cannot be undone.
#
# bash ~/ml4t/libraries/ml4t-coursework/publish.sh
#
# Needs a PyPI API token. Either export it first:
# export UV_PUBLISH_TOKEN=pypi-...
# or let uv prompt for it.
# Qualify a release and push its tag. GitHub Actions publishes to PyPI through OIDC.
set -euo pipefail

cd "$(dirname "$0")"

# ---------------------------------------------------------------------------
# The package moved out of foundations-dev on 2026-08-29 and is its own repo
# here. The hold that blocked the upload until it did is gone; what is left is
# the reason that hold existed.
#
# The order cannot be redone. Claiming a PyPI name is permanent, and the first
# release's metadata is what the project page shows forever after. pyproject.toml
# points Homepage, Repository, Issues and Changelog at github.com/ml4t/coursework,
# so that repository has to exist and this checkout has to be it before the first
# upload freezes a link to a 404.
# ---------------------------------------------------------------------------
echo "== source =="
version=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)
tag="v${version}"
origin=$(git remote get-url origin 2>/dev/null || true)

case "$origin" in
*ml4t/coursework*) ;;
*)
echo " origin is '${origin:-unset}', not ml4t/coursework."
echo " Refusing to upload: the metadata names a repository this checkout is not."
echo "origin is '${origin:-unset}', not ml4t/coursework"
exit 1
;;
esac
if ! git ls-remote --exit-code origin HEAD >/dev/null 2>&1; then
echo " ${origin} does not exist yet, or is unreachable."
echo " Create and push it first. The first release freezes a link to it and a release"
echo " cannot be redone, so a source link that 404s on day one stays wrong."

if [ "$(git branch --show-current)" != "main" ]; then
echo "release from main, not $(git branch --show-current)"
exit 1
fi
if [ -n "$(git status --porcelain)" ]; then
echo " the working tree is dirty. Upload what is committed, not what is on disk."
echo "the working tree is dirty"
exit 1
fi
if [ "$(git rev-parse HEAD)" != "$(git rev-parse @{u} 2>/dev/null)" ]; then
echo " HEAD is not what origin has. Push first: the release points at a commit"
echo " nobody else can fetch otherwise."
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
echo "HEAD does not match origin/main"
exit 1
fi
echo " origin ${origin} exists and matches HEAD"

# The source moved; these check that the metadata moved with it. The two can come
# apart, and only one of them is visible on PyPI.
echo "== metadata =="
metadata_fault=0
for key in Homepage Repository Issues Changelog; do
if ! grep -q "^${key} = " pyproject.toml; then
echo " MISSING [project.urls] ${key}"
metadata_fault=1
fi
done
if ! curl -Lsf -o /dev/null "https://github.com/ml4t/coursework"; then
echo " github.com/ml4t/coursework is not publicly reachable."
metadata_fault=1
fi
if grep -q "Proprietary" pyproject.toml; then
echo " pyproject.toml still says Proprietary. The licence is MIT, like every sibling."
metadata_fault=1
fi
if ! grep -q 'license = { text = "MIT" }' pyproject.toml; then
echo " MISSING the MIT licence declaration"
metadata_fault=1
fi
if [ ! -f LICENSE ]; then
echo " MISSING the LICENSE file"
metadata_fault=1
echo "github.com/ml4t/coursework is not publicly reachable"
exit 1
fi
if grep -q "ml4trading.io\"*$" <(grep "^Homepage" pyproject.toml); then
echo " Homepage points at the marketing site. House pattern points it at the repo."
metadata_fault=1
if git show-ref --verify --quiet "refs/tags/${tag}" ||
git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
echo "${tag} already exists"
exit 1
fi
if [ "$metadata_fault" -ne 0 ]; then
echo
echo "Refusing to upload. The first release freezes all of this and a release cannot be redone."
if curl -Lsf -o /dev/null "https://pypi.org/pypi/ml4t-coursework/${version}/json"; then
echo "ml4t-coursework ${version} already exists on PyPI"
exit 1
fi
echo " urls, licence and LICENSE file all present"

echo "== tests =="
uv run pytest -q

echo
echo "== is the name still free? =="
if curl -sf -o /dev/null "https://pypi.org/pypi/ml4t-coursework/json"; then
echo " ml4t-coursework already EXISTS on PyPI."
echo " This would be a new release of an existing project, not a first claim."
echo " Check the version in pyproject.toml before continuing; an existing version cannot"
echo " be overwritten and a wrong one cannot be withdrawn."
read -r -p " Continue? [y/N] " reply
[[ "$reply" == "y" || "$reply" == "Y" ]] || { echo " stopped"; exit 1; }
else
echo " ml4t-coursework is unclaimed. This upload claims it."
fi
uv sync --all-extras --locked
OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 uv run pytest -q
uv run ruff check --isolated --select E4,E7,E9,F src tests

echo
echo "== build =="
rm -rf dist
uv build
ls -la dist/
uv run twine check dist/*

echo
VERSION=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)
echo "== about to upload version ${VERSION} to PyPI =="
echo " This is public and permanent. A version number cannot be reused, even after deletion."
read -r -p " Type the version to confirm: " confirm
[[ "$confirm" == "$VERSION" ]] || { echo " mismatch, stopped"; exit 1; }

uv publish
echo "About to push ${tag}. GitHub Actions will publish it to PyPI."
read -r -p "Type the version to confirm: " confirm
if [ "$confirm" != "$version" ]; then
echo "mismatch, stopped"
exit 1
fi

echo
echo "== verify the published artifact installs =="
rm -rf /tmp/ml4t-pypi-check
uv venv --python 3.11 /tmp/ml4t-pypi-check -q
echo " waiting for the index to catch up before installing"
until uv pip install --python /tmp/ml4t-pypi-check -q "ml4t-coursework==${VERSION}" 2>/dev/null; do
sleep 5
done
/tmp/ml4t-pypi-check/bin/python -c "
import ml4t_coursework as m
from ml4t_coursework import contracts
print('installed', m.__version__, 'with', len(contracts.load_all()), 'components')"
echo
echo "Done. Change the first cell of the setup notebook and every unit notebook to:"
echo " %pip install -q \"ml4t-coursework[data]\""
git tag -a "$tag" -m "ml4t-coursework ${version}"
git push origin "$tag"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Issues = "https://github.com/ml4t/coursework/issues"
Changelog = "https://github.com/ml4t/coursework/releases"

[project.optional-dependencies]
dev = ["pytest>=8.0"]
dev = ["pytest>=8.0", "ruff==0.16.6", "twine==7.0.0"]
data = ["yfinance>=0.2.40"]

[tool.hatch.build.targets.wheel]
Expand Down
Loading
Loading