diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ace93b1..5928dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,12 @@ name: CI on: + workflow_call: + inputs: + release_version: + description: Version the candidate must expose when called for a release + required: false + type: string push: branches: [main, develop] pull_request: @@ -27,7 +33,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -36,7 +42,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies - run: uv sync --dev --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --python "${{ steps.setup-python.outputs.python-path }}" - name: Run Ruff check run: uv run ruff check src/ tests/ examples/ scripts/ @@ -53,7 +59,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -62,7 +68,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies - run: uv sync --dev --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --python "${{ steps.setup-python.outputs.python-path }}" - name: Run ty check run: uv run ty check @@ -71,7 +77,8 @@ jobs: run: >- uv run ty check scripts/ci/candidate.py scripts/ci/check_coverage.py scripts/ci/check_performance.py scripts/ci/hardware_qualification.py - scripts/ci/performance_qualification.py scripts/ci/test_wheel.py + scripts/ci/performance_qualification.py scripts/ci/readme_smoke.py + scripts/ci/release.py scripts/ci/test_wheel.py scripts/ci/verify_docs_deployment.py coverage: name: Coverage @@ -82,7 +89,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -91,7 +98,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies - run: uv sync --dev --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --python "${{ steps.setup-python.outputs.python-path }}" - name: Measure coverage run: uv run pytest tests/ -q --cov-report=json:coverage.json @@ -108,7 +115,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -117,7 +124,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies - run: uv sync --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" - name: Build docs run: uv run mkdocs build --strict @@ -134,7 +141,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -143,7 +150,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install build checks - run: uv sync --dev --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --python "${{ steps.setup-python.outputs.python-path }}" - name: Build candidate run: uv build --out-dir candidate/dist @@ -152,7 +159,7 @@ jobs: run: >- uv run python scripts/ci/candidate.py create candidate --commit-sha "${{ github.sha }}" - --git-tree "$(git rev-parse HEAD^{tree})" + --git-tree "$(git rev-parse 'HEAD^{tree}')" - name: Verify package metadata run: uv run twine check candidate/dist/* @@ -161,7 +168,13 @@ jobs: run: >- uv run python scripts/ci/candidate.py verify candidate --expected-commit "${{ github.sha }}" - --expected-tree "$(git rev-parse HEAD^{tree})" + --expected-tree "$(git rev-parse 'HEAD^{tree}')" + + - name: Verify requested release version + if: ${{ inputs.release_version != '' }} + run: >- + uv run python scripts/ci/candidate.py verify candidate + --expected-tag "v${{ inputs.release_version }}" - name: Upload candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -185,7 +198,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -205,7 +218,7 @@ jobs: --expected-commit "${{ github.sha }}" - name: Install development environment - run: uv sync --dev --python "${{ steps.setup-python.outputs.python-path }}" + run: uv sync --locked --dev --python "${{ steps.setup-python.outputs.python-path }}" - name: Run source tests run: uv run pytest tests/ -q --tb=short @@ -243,7 +256,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -259,7 +272,7 @@ jobs: - name: Audit current contributor environment run: | - uv sync --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" + uv sync --locked --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" uv run --with pip-audit --with 'setuptools>=83.0.0' python -m pip_audit \ --progress-spinner off @@ -292,7 +305,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up current Python prerelease id: setup-python @@ -331,7 +344,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -365,7 +378,9 @@ jobs: cuda-qualification: name: RTX 3090 CUDA and Performance Qualification - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: >- + ${{ inputs.release_version != '' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') }} runs-on: [self-hosted, linux, x64, cuda, rtx-3090] needs: build-candidate timeout-minutes: 180 @@ -380,7 +395,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - name: Set up Python id: setup-python @@ -467,12 +482,14 @@ jobs: EVENT_NAME: ${{ github.event_name }} PYTHON_RESULT: ${{ needs.python-prerelease.result }} REF_NAME: ${{ github.ref }} + RELEASE_VERSION: ${{ inputs.release_version }} STABLE_RESULT: ${{ needs.stable-platform.result }} run: | test "$STABLE_RESULT" = success test "$PYTHON_RESULT" = success test "$DEPENDENCY_RESULT" = success - if [[ "$EVENT_NAME" = push && "$REF_NAME" = refs/heads/main ]]; then + if [[ -n "$RELEASE_VERSION" ]] || \ + [[ "$EVENT_NAME" = push && "$REF_NAME" = refs/heads/main ]]; then test "$CUDA_RESULT" = success else test "$CUDA_RESULT" = skipped diff --git a/.github/workflows/ecosystem.yml b/.github/workflows/ecosystem.yml index eddd186..7ef8e09 100644 --- a/.github/workflows/ecosystem.yml +++ b/.github/workflows/ecosystem.yml @@ -17,7 +17,7 @@ jobs: qualification: permissions: contents: read - uses: ml4t/ecosystem/.github/workflows/qualify-library.yml@457678d8860d0c816b1dc376e7e2691daa93af4d # v0.1.0 + uses: ml4t/ecosystem/.github/workflows/qualify-library.yml@da2cbb9a9d0b4d2f166ad630470ff7d7a1aa2643 # 2026-09-19 policy snapshot with: import-package: ml4t.models prerelease-test-paths: >- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d6a58f..dbf9671 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,121 +1,155 @@ name: Release on: - push: - tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: Package version without the v prefix + required: true + type: string + candidate_commit: + description: Full commit SHA currently at the tip of main + required: true + type: string permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.version }} cancel-in-progress: false jobs: + validate: + name: Validate Release Request + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + commit: ${{ steps.identity.outputs.commit }} + tree: ${{ steps.identity.outputs.tree }} + version: ${{ steps.identity.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + + - name: Fetch current main and release tags + run: git fetch origin main --tags + + - name: Require the current main commit and an unused version + env: + GH_TOKEN: ${{ github.token }} + run: >- + python scripts/ci/release.py preflight + --version "${{ inputs.version }}" + --candidate-commit "${{ inputs.candidate_commit }}" + --workflow-commit "${{ github.sha }}" + --repository "${{ github.repository }}" + + - name: Record accepted release identity + id: identity + run: | + { + echo "commit=${{ inputs.candidate_commit }}" + echo "tree=$(git rev-parse 'HEAD^{tree}')" + echo "version=${{ inputs.version }}" + } >> "$GITHUB_OUTPUT" + ecosystem-qualification: name: Ecosystem Qualification + needs: validate permissions: contents: read - uses: ml4t/ecosystem/.github/workflows/qualify-library.yml@457678d8860d0c816b1dc376e7e2691daa93af4d # v0.1.0 + uses: ml4t/ecosystem/.github/workflows/qualify-library.yml@da2cbb9a9d0b4d2f166ad630470ff7d7a1aa2643 # 2026-09-19 policy snapshot with: import-package: ml4t.models prerelease-test-paths: >- ["tests/test_configs.py", "tests/test_forecasters.py", "tests/test_integration_data.py", "tests/test_pca_pipeline.py", "tests/test_rp_pca.py", "tests/test_types.py"] + qualification: + name: Qualify Release Candidate + needs: validate + permissions: + contents: read + uses: ./.github/workflows/ci.yml + with: + release_version: ${{ needs.validate.outputs.version }} + select-candidate: name: Select Qualified Candidate runs-on: ubuntu-latest + needs: [validate, ecosystem-qualification, qualification] permissions: actions: read contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.validate.outputs.commit }} fetch-depth: 0 - - name: Find successful main CI run - id: ci-run - env: - GH_TOKEN: ${{ github.token }} - run: | - run_id="$(gh run list \ - --repo "${{ github.repository }}" \ - --workflow ci.yml \ - --commit "${{ github.sha }}" \ - --branch main \ - --event push \ - --status success \ - --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId')" - if [[ -z "$run_id" ]]; then - echo "No successful main CI run exists for ${{ github.sha }}" >&2 - exit 1 - fi - echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - - - name: Download qualified candidate - env: - GH_TOKEN: ${{ github.token }} - run: >- - gh run download "${{ steps.ci-run.outputs.run-id }}" - --repo "${{ github.repository }}" - --name release-candidate - --dir candidate + - name: Download the candidate qualified by CI + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate + path: candidate - - name: Verify tag and candidate identity + - name: Verify candidate commit, tree, version, and SHA256 manifest run: >- python scripts/ci/candidate.py verify candidate - --expected-commit "${{ github.sha }}" - --expected-tree "$(git rev-parse HEAD^{tree})" - --expected-tag "${{ github.ref_name }}" + --expected-commit "${{ needs.validate.outputs.commit }}" + --expected-tree "${{ needs.validate.outputs.tree }}" + --expected-tag "v${{ needs.validate.outputs.version }}" - - name: Retain selected candidate + - name: Retain the selected candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: qualified-release-candidate path: candidate/ if-no-files-found: error + retention-days: 90 docs: name: Publish Qualified Documentation runs-on: ubuntu-latest - needs: select-candidate + needs: [validate, select-candidate] + environment: documentation permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.validate.outputs.commit }} - - name: Install uv - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: - version: "latest" + version: "0.10.9" - - name: Set up Python - id: setup-python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Download selected candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: qualified-release-candidate path: candidate - - name: Resolve release identity - id: identity - run: | - version="$(python -c 'import json; print(json.load(open("candidate/candidate.json"))["version"])')" - echo "version=${version}" >> "$GITHUB_OUTPUT" + - name: Verify selected candidate + run: >- + python scripts/ci/candidate.py verify candidate + --expected-commit "${{ needs.validate.outputs.commit }}" + --expected-tree "${{ needs.validate.outputs.tree }}" + --expected-tag "v${{ needs.validate.outputs.version }}" - name: Build revision-identified documentation env: - ML4T_DOCS_VERSION: ${{ steps.identity.outputs.version }} - ML4T_DOCS_COMMIT: ${{ github.sha }} + ML4T_DOCS_COMMIT: ${{ needs.validate.outputs.commit }} + ML4T_DOCS_VERSION: ${{ needs.validate.outputs.version }} run: | - uv sync --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" + uv sync --locked --dev --extra docs --python 3.12 uv run mkdocs build --strict python - <<'PY' import json @@ -126,6 +160,7 @@ jobs: json.dumps( { "commit": os.environ["ML4T_DOCS_COMMIT"], + "library": "models", "version": os.environ["ML4T_DOCS_VERSION"], }, sort_keys=True, @@ -138,11 +173,7 @@ jobs: - name: Require deploy credentials env: DOCS_DEPLOY_KEY: ${{ secrets.DOCS_DEPLOY_KEY }} - run: | - if [[ -z "$DOCS_DEPLOY_KEY" ]]; then - echo "DOCS_DEPLOY_KEY is required for a stable release" >&2 - exit 1 - fi + run: test -n "$DOCS_DEPLOY_KEY" - name: Deploy stable documentation uses: cpina/github-action-push-to-another-repository@55306faa4ed53b815ae49e564af8cfb359d32ae2 # v1.7.3 @@ -154,7 +185,7 @@ jobs: destination-repository-name: website target-directory: static/docs/models/ target-branch: main - commit-message: "docs(models): publish ${{ github.ref_name }} from ${{ github.sha }}" + commit-message: "docs(models): publish v${{ needs.validate.outputs.version }} from ${{ needs.validate.outputs.commit }}" user-name: ml4t-bot user-email: bot@ml4trading.io @@ -166,64 +197,158 @@ jobs: source-directory: site/ destination-github-username: ml4t destination-repository-name: website - target-directory: static/docs/models/releases/${{ steps.identity.outputs.version }}/ + target-directory: static/docs/models/releases/${{ needs.validate.outputs.version }}/ target-branch: main - commit-message: "docs(models): retain ${{ github.ref_name }} from ${{ github.sha }}" + commit-message: "docs(models): retain v${{ needs.validate.outputs.version }} from ${{ needs.validate.outputs.commit }}" user-name: ml4t-bot user-email: bot@ml4trading.io - name: Verify deployed documentation identity env: - RELEASE_VERSION: ${{ steps.identity.outputs.version }} - RELEASE_COMMIT: ${{ github.sha }} + RELEASE_COMMIT: ${{ needs.validate.outputs.commit }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: uv run python scripts/ci/verify_docs_deployment.py publish: - name: Publish to PyPI + name: Publish Exact Candidate to PyPI runs-on: ubuntu-latest - needs: [ecosystem-qualification, select-candidate, docs] + needs: [validate, ecosystem-qualification, select-candidate, docs] environment: pypi permissions: + contents: read id-token: write steps: - - name: Download selected candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: qualified-release-candidate path: candidate - - name: Publish to PyPI + - name: Publish the qualified artifacts uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: candidate/dist/ + verify-metadata: true print-hash: true + attestations: true github-release: - name: Create GitHub Release + name: Create Commit-Bound GitHub Release runs-on: ubuntu-latest - needs: publish + needs: [validate, publish] permissions: contents: write steps: - - name: Download selected candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: qualified-release-candidate path: candidate - - name: Create GitHub Release + - name: Create tag and release from the candidate commit env: + CANDIDATE_COMMIT: ${{ needs.validate.outputs.commit }} GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: | - tag="${{ github.ref_name }}" args=( - "$tag" + "v${RELEASE_VERSION}" --repo "${{ github.repository }}" + --target "$CANDIDATE_COMMIT" --generate-notes candidate/dist/* candidate/candidate.json ) - if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)[0-9]+$ ]]; then + if [[ "$RELEASE_VERSION" =~ (a|b|rc)[0-9]+$ ]]; then args+=(--prerelease) fi gh release create "${args[@]}" + + verify-release: + name: Verify Published Release + runs-on: ubuntu-latest + needs: [validate, github-release] + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.validate.outputs.commit }} + fetch-depth: 0 + + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.10.9" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: qualified-release-candidate + path: candidate + + - name: Verify PyPI metadata and artifact SHA256 digests + run: python scripts/ci/release.py verify candidate + + - name: Install the published wheel and execute the README quick start + run: >- + python scripts/ci/release.py smoke-test + ml4t-models "${{ needs.validate.outputs.version }}" + scripts/ci/readme_smoke.py + + - name: Download and verify GitHub release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + mkdir -p released/dist + gh release download "v${RELEASE_VERSION}" --repo "${{ github.repository }}" \ + --pattern candidate.json --dir released + gh release download "v${RELEASE_VERSION}" --repo "${{ github.repository }}" \ + --pattern '*.whl' --pattern '*.tar.gz' --dir released/dist + python scripts/ci/candidate.py verify released \ + --expected-commit "${{ needs.validate.outputs.commit }}" \ + --expected-tree "${{ needs.validate.outputs.tree }}" \ + --expected-tag "v${RELEASE_VERSION}" + + - name: Repeat deployed documentation identity check + env: + RELEASE_COMMIT: ${{ needs.validate.outputs.commit }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: python scripts/ci/verify_docs_deployment.py + + recovery: + name: Record Post-Publication Recovery + runs-on: ubuntu-latest + needs: [validate, publish, github-release, verify-release] + if: >- + ${{ always() && needs.validate.result == 'success' && + needs.publish.result != 'skipped' && + (needs.publish.result != 'success' || needs.github-release.result != 'success' || + needs.verify-release.result != 'success') }} + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.validate.outputs.commit }} + + - name: Check whether publication crossed the PyPI boundary + id: publication + continue-on-error: true + run: >- + python scripts/ci/release.py publication-exists + ml4t-models "${{ needs.validate.outputs.version }}" + + - name: Create the recovery issue once + if: ${{ steps.publication.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ needs.validate.outputs.commit }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + marker="release-recovery:v${RELEASE_VERSION}:${RELEASE_COMMIT}" + if gh issue list --repo "${{ github.repository }}" --state all \ + --search "$marker" --json number --jq 'length' | grep -qx '0'; then + gh issue create \ + --repo "${{ github.repository }}" \ + --title "Recover incomplete v${RELEASE_VERSION} publication" \ + --body "PyPI accepted v${RELEASE_VERSION} from ${RELEASE_COMMIT}, but a later release stage failed. Inspect workflow run ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. Rerun only failed jobs so the retained candidate is reused. Do not rebuild or reuse this version for different bytes. ${marker}" + fi diff --git a/pyproject.toml b/pyproject.toml index 9d05fe4..d86713e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ include = [ [project] name = "ml4t-models" dynamic = ["version"] -description = "Finance-specific models for asset pricing, prediction, and portfolio learning" +description = "Finance-specific models for asset pricing, prediction, and portfolio learning." readme = "README.md" license = { text = "MIT" } authors = [ diff --git a/scripts/ci/candidate.py b/scripts/ci/candidate.py index a53b0d2..e515f5e 100644 --- a/scripts/ci/candidate.py +++ b/scripts/ci/candidate.py @@ -5,6 +5,7 @@ import json import tarfile import zipfile +from email.message import Message from email.parser import BytesParser from pathlib import Path from typing import Any @@ -31,22 +32,25 @@ def _distribution_files(candidate_dir: Path) -> tuple[Path, Path]: return wheels[0], sdists[0] -def _wheel_identity(wheel: Path) -> tuple[str, str]: +def _wheel_metadata(wheel: Path) -> Message: with zipfile.ZipFile(wheel) as archive: metadata_names = [ name for name in archive.namelist() if name.endswith(".dist-info/METADATA") ] if len(metadata_names) != 1: raise ValueError(f"wheel must contain one METADATA file: {wheel}") - metadata = BytesParser().parsebytes(archive.read(metadata_names[0])) + return BytesParser().parsebytes(archive.read(metadata_names[0])) + + +def _identity(metadata: Message, path: Path) -> tuple[str, str]: name = metadata.get("Name") version = metadata.get("Version") if not name or not version: - raise ValueError(f"wheel metadata is missing Name or Version: {wheel}") + raise ValueError(f"distribution metadata is missing Name or Version: {path}") return name, version -def _sdist_identity(sdist: Path) -> tuple[str, str]: +def _sdist_metadata(sdist: Path) -> Message: with tarfile.open(sdist, "r:gz") as archive: metadata_members = [ member @@ -58,12 +62,26 @@ def _sdist_identity(sdist: Path) -> tuple[str, str]: stream = archive.extractfile(metadata_members[0]) if stream is None: raise ValueError(f"cannot read sdist metadata: {sdist}") - metadata = BytesParser().parsebytes(stream.read()) - name = metadata.get("Name") - version = metadata.get("Version") - if not name or not version: - raise ValueError(f"sdist metadata is missing Name or Version: {sdist}") - return name, version + return BytesParser().parsebytes(stream.read()) + + +def _public_metadata(metadata: Message) -> dict[str, Any]: + project_urls = {} + for value in metadata.get_all("Project-URL", []): + label, url = value.split(",", maxsplit=1) + project_urls[label.strip()] = url.strip() + return { + "author_email": metadata.get("Author-email"), + "classifiers": sorted(metadata.get_all("Classifier", [])), + "description": metadata.get("Summary"), + "keywords": sorted( + keyword.strip() for keyword in (metadata.get("Keywords") or "").split(",") if keyword + ), + "license": metadata.get("License-Expression") or metadata.get("License"), + "maintainer_email": metadata.get("Maintainer-email"), + "project_urls": project_urls, + "requires_python": metadata.get("Requires-Python"), + } def _artifact_record(path: Path) -> dict[str, str | int]: @@ -72,19 +90,25 @@ def _artifact_record(path: Path) -> dict[str, str | int]: def create(candidate_dir: Path, commit_sha: str, git_tree: str) -> None: wheel, sdist = _distribution_files(candidate_dir) - wheel_identity = _wheel_identity(wheel) - sdist_identity = _sdist_identity(sdist) + wheel_metadata = _wheel_metadata(wheel) + sdist_metadata = _sdist_metadata(sdist) + wheel_identity = _identity(wheel_metadata, wheel) + sdist_identity = _identity(sdist_metadata, sdist) if wheel_identity != sdist_identity: raise ValueError( f"wheel identity {wheel_identity!r} does not match sdist identity {sdist_identity!r}" ) name, version = wheel_identity + public_metadata = _public_metadata(wheel_metadata) + if public_metadata != _public_metadata(sdist_metadata): + raise ValueError("wheel and sdist public metadata do not match") manifest = { "schema_version": SCHEMA_VERSION, "commit_sha": commit_sha, "git_tree": git_tree, "name": name, "version": version, + "metadata": public_metadata, "artifacts": [_artifact_record(wheel), _artifact_record(sdist)], } (candidate_dir / "candidate.json").write_text( @@ -132,11 +156,18 @@ def verify( if record.get("sha256") != _sha256(path) or record.get("size") != path.stat().st_size: raise ValueError(f"candidate artifact integrity check failed: {filename}") - identity = _wheel_identity(wheel) - if identity != _sdist_identity(sdist): + wheel_metadata = _wheel_metadata(wheel) + sdist_metadata = _sdist_metadata(sdist) + identity = _identity(wheel_metadata, wheel) + if identity != _identity(sdist_metadata, sdist): raise ValueError("candidate wheel and sdist identities do not match") if identity != (manifest.get("name"), manifest.get("version")): raise ValueError("candidate metadata does not match the manifest") + public_metadata = _public_metadata(wheel_metadata) + if public_metadata != _public_metadata(sdist_metadata): + raise ValueError("candidate wheel and sdist public metadata do not match") + if public_metadata != manifest.get("metadata"): + raise ValueError("candidate public metadata does not match the manifest") def compare_wheel( diff --git a/scripts/ci/readme_smoke.py b/scripts/ci/readme_smoke.py new file mode 100644 index 0000000..4ca1ef3 --- /dev/null +++ b/scripts/ci/readme_smoke.py @@ -0,0 +1,44 @@ +"""Execute the first README quick-start example against an installed distribution.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +def extract_quick_start(markdown: str) -> str: + _, marker, remainder = markdown.partition("## Quick Start") + if not marker: + raise ValueError("README has no Quick Start section") + section = remainder.partition("\n## ")[0] + blocks = re.findall(r"```python\r?\n(.*?)```", section, re.DOTALL) + if not blocks: + raise ValueError("README Quick Start has no Python example") + return blocks[0] + + +def run(readme: Path, expected_version: str) -> None: + import numpy as np + + import ml4t.models as models + + if models.__version__ != expected_version: + raise RuntimeError( + f"installed version {models.__version__!r} does not match {expected_version!r}" + ) + np.random.seed(0) + source = extract_quick_start(readme.read_text(encoding="utf-8")) + exec(compile(source, "README.md", "exec"), {"__name__": "__main__"}) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--expected-version", required=True) + parser.add_argument("--readme", type=Path, default=Path(__file__).parents[2] / "README.md") + args = parser.parse_args() + run(args.readme, args.expected_version) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/release.py b/scripts/ci/release.py new file mode 100644 index 0000000..6d2cf1a --- /dev/null +++ b/scripts/ci/release.py @@ -0,0 +1,214 @@ +"""Validate and verify commit-bound ml4t-models releases.""" + +from __future__ import annotations + +import argparse +import json +import re +import runpy +import subprocess +import time +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +PYPI_URL = "https://pypi.org/pypi/{name}/{version}/json" +USER_AGENT = "ml4t-models-release-verifier/1.0" +VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?$") +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") + + +def _package(name: str, version: str) -> dict[str, Any]: + request = Request( + PYPI_URL.format(name=name, version=version), headers={"User-Agent": USER_AGENT} + ) + with urlopen(request, timeout=20) as response: + value = json.load(response) + if not isinstance(value, dict): + raise ValueError("PyPI response must be a JSON object") + return value + + +def version_exists(name: str, version: str) -> bool: + try: + _package(name, version) + except HTTPError as error: + if error.code == 404: + return False + raise + return True + + +def preflight_failures( + *, + version: str, + source_version: str, + candidate_commit: str, + workflow_commit: str, + checkout_commit: str, + main_commit: str, + tag_exists: bool, + release_exists: bool, + pypi_exists: bool, +) -> list[str]: + failures = [] + if VERSION_PATTERN.fullmatch(version) is None: + failures.append(f"version must be a stable or prerelease PEP 440 version: {version!r}") + if COMMIT_PATTERN.fullmatch(candidate_commit) is None: + failures.append("candidate_commit must be a full lowercase commit SHA") + for label, observed in ( + ("workflow", workflow_commit), + ("checkout", checkout_commit), + ("origin/main", main_commit), + ): + if candidate_commit != observed: + failures.append( + f"candidate commit differs from {label}: {candidate_commit} != {observed}" + ) + if source_version != version: + failures.append( + f"source version differs from requested release: {source_version} != {version}" + ) + if tag_exists: + failures.append(f"tag already exists: v{version}") + if release_exists: + failures.append(f"GitHub release already exists: v{version}") + if pypi_exists: + failures.append(f"PyPI version already exists: {version}") + return failures + + +def _public_metadata(info: dict[str, Any]) -> dict[str, Any]: + return { + "author_email": info.get("author_email"), + "classifiers": sorted(info.get("classifiers", [])), + "description": info.get("summary"), + "keywords": sorted( + keyword.strip() for keyword in (info.get("keywords") or "").split(",") if keyword + ), + "license": info.get("license_expression") or info.get("license"), + "maintainer_email": info.get("maintainer_email"), + "project_urls": info.get("project_urls"), + "requires_python": info.get("requires_python"), + } + + +def verify_publication(candidate_dir: Path) -> None: + manifest = json.loads((candidate_dir / "candidate.json").read_text(encoding="utf-8")) + package = _package(manifest["name"], manifest["version"]) + info = package.get("info") + if not isinstance(info, dict): + raise ValueError("PyPI response has no project metadata") + if (info.get("name"), info.get("version")) != (manifest["name"], manifest["version"]): + raise ValueError("PyPI project identity does not match the candidate manifest") + if _public_metadata(info) != manifest.get("metadata"): + raise ValueError("PyPI public metadata does not match the candidate manifest") + + published = { + item.get("filename"): (item.get("digests", {}).get("sha256"), item.get("size")) + for item in package.get("urls", []) + if isinstance(item, dict) + } + expected = {item["filename"]: (item["sha256"], item["size"]) for item in manifest["artifacts"]} + if published != expected: + raise ValueError("PyPI artifacts do not match the candidate manifest") + + +def verify_install( + name: str, + version: str, + script: Path, + *, + attempts: int = 12, + retry_seconds: int = 10, +) -> None: + command = [ + "uv", + "run", + "--isolated", + "--no-project", + "--refresh-package", + name, + "--with", + f"{name}=={version}", + "python", + "-I", + str(script), + "--expected-version", + version, + ] + for attempt in range(attempts): + result = subprocess.run(command, check=False) + if result.returncode == 0: + return + if attempt + 1 < attempts: + time.sleep(retry_seconds) + raise RuntimeError(f"failed to install and exercise {name} {version} from PyPI") + + +def _run_preflight(args: argparse.Namespace) -> None: + source_version = runpy.run_path("src/ml4t/models/_version.py")["__version__"] + tag = f"v{args.version}" + failures = preflight_failures( + version=args.version, + source_version=source_version, + candidate_commit=args.candidate_commit, + workflow_commit=args.workflow_commit, + checkout_commit=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + main_commit=subprocess.check_output(["git", "rev-parse", "origin/main"], text=True).strip(), + tag_exists=( + subprocess.run( + ["git", "show-ref", "--verify", "--quiet", f"refs/tags/{tag}"], check=False + ).returncode + == 0 + ), + release_exists=( + subprocess.run( + ["gh", "release", "view", tag, "--repo", args.repository], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ), + pypi_exists=version_exists("ml4t-models", args.version), + ) + if failures: + raise SystemExit("\n".join(failures)) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + preflight = subparsers.add_parser("preflight") + preflight.add_argument("--version", required=True) + preflight.add_argument("--candidate-commit", required=True) + preflight.add_argument("--workflow-commit", required=True) + preflight.add_argument("--repository", required=True) + verify = subparsers.add_parser("verify") + verify.add_argument("candidate_dir", type=Path) + smoke = subparsers.add_parser("smoke-test") + smoke.add_argument("name") + smoke.add_argument("version") + smoke.add_argument("script", type=Path) + present = subparsers.add_parser("publication-exists") + present.add_argument("name") + present.add_argument("version") + return parser + + +def main() -> None: + args = _parser().parse_args() + if args.command == "preflight": + _run_preflight(args) + elif args.command == "verify": + verify_publication(args.candidate_dir) + elif args.command == "smoke-test": + verify_install(args.name, args.version, args.script) + elif not version_exists(args.name, args.version): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/test_wheel.py b/scripts/ci/test_wheel.py index 5e15a1a..2ac3338 100644 --- a/scripts/ci/test_wheel.py +++ b/scripts/ci/test_wheel.py @@ -16,6 +16,15 @@ "test_types.py", ) +REPOSITORY_ONLY_TESTS = frozenset( + { + "test_coverage_gate.py", + "test_release_candidate.py", + "test_release_workflow.py", + "test_repo_hygiene.py", + } +) + DOCUMENTATION_TEST_INPUTS = ( Path("README.md"), Path("docs/getting-started/quickstart.md"), @@ -48,6 +57,17 @@ def _new_venv(root: Path, name: str, python_version: str) -> Path: return _python_executable(venv) +def _installed_test_paths(root: Path, mode: str) -> list[str]: + tests = root / "tests" + if mode == "core": + return [str(tests / name) for name in CORE_TESTS] + return [ + str(path) + for path in sorted(tests.glob("test_*.py")) + if path.name not in REPOSITORY_ONLY_TESTS + ] + + def _core_smoke(python: Path) -> None: script = """ import importlib.resources @@ -151,26 +171,13 @@ def test_wheel(candidate_dir: Path, python_version: str, mode: str) -> None: destination = root / source destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) - if mode == "full": - selected_tests = [str(root / "tests")] - ignored = [ - "--ignore", - str(root / "tests" / "test_release_candidate.py"), - "--ignore", - str(root / "tests" / "test_repo_hygiene.py"), - "--ignore", - str(root / "tests" / "test_coverage_gate.py"), - ] - else: - selected_tests = [str(root / "tests" / name) for name in CORE_TESTS] - ignored = [] + selected_tests = _installed_test_paths(root, mode) _run( [ str(test_python), "-m", "pytest", *selected_tests, - *ignored, "-q", "--tb=short", ], diff --git a/scripts/ci/verify_docs_deployment.py b/scripts/ci/verify_docs_deployment.py index f006cc0..f72d098 100644 --- a/scripts/ci/verify_docs_deployment.py +++ b/scripts/ci/verify_docs_deployment.py @@ -26,8 +26,8 @@ def verify( urls: tuple[str, ...], expected: dict[str, str], *, - attempts: int = 20, - retry_seconds: float = 15, + attempts: int = 24, + retry_seconds: float = 10, ) -> None: if attempts < 1: raise ValueError("attempts must be positive") @@ -55,12 +55,13 @@ def verify( def main() -> None: expected = { "commit": os.environ["RELEASE_COMMIT"], + "library": "models", "version": os.environ["RELEASE_VERSION"], } verify( ( - "https://ml4trading.io/docs/models/release.json", - f"https://ml4trading.io/docs/models/releases/{expected['version']}/release.json", + "https://www.ml4trading.io/docs/models/release.json", + f"https://www.ml4trading.io/docs/models/releases/{expected['version']}/release.json", ), expected, ) diff --git a/src/ml4t/models/_version.py b/src/ml4t/models/_version.py index 51dd695..b227e06 100644 --- a/src/ml4t/models/_version.py +++ b/src/ml4t/models/_version.py @@ -1,3 +1,3 @@ """Package version shared by public and internal runtime metadata.""" -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index d73777e..3de7bf2 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -44,6 +44,8 @@ def test_candidate_manifest_binds_artifacts_to_source_identity(candidate_dir: Pa assert manifest["name"] == "ml4t-models" assert manifest["version"] == __version__ + assert manifest["metadata"]["description"] + assert manifest["metadata"]["project_urls"] assert {record["filename"] for record in manifest["artifacts"]} == { f"ml4t_models-{__version__}-py3-none-any.whl", f"ml4t_models-{__version__}.tar.gz", @@ -126,7 +128,7 @@ def test_candidate_distributions_expose_canonical_project_metadata(candidate_dir assert metadata["Author-email"] == "Stefan Jansen " assert metadata["Maintainer-email"] == "Stefan Jansen " assert metadata["Summary"] == ( - "Finance-specific models for asset pricing, prediction, and portfolio learning" + "Finance-specific models for asset pricing, prediction, and portfolio learning." ) project_urls = metadata.get_all("Project-URL", []) assert "Homepage, https://www.ml4trading.io/" in project_urls @@ -254,6 +256,20 @@ def run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: ] +def test_installed_wheel_suite_excludes_repository_only_tests(tmp_path: Path) -> None: + tests = tmp_path / "tests" + tests.mkdir() + package_test = tests / "test_types.py" + package_test.touch() + for name in test_wheel.REPOSITORY_ONLY_TESTS: + (tests / name).touch() + + selected = test_wheel._installed_test_paths(tmp_path, "full") + + assert selected == [str(package_test)] + assert "test_release_workflow.py" in test_wheel.REPOSITORY_ONLY_TESTS + + def test_hardware_qualification_separates_replay_and_cpu_recovery_tolerances() -> None: expected = np.zeros(1) cross_backend = np.full(1, 2e-5) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..fe18db6 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,255 @@ +"""Behavioral contracts for commit-bound release publication.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +from ml4t.models import __version__ +from scripts.ci import readme_smoke, release, verify_docs_deployment + +ROOT = Path(__file__).parents[1] +COMMIT = "a" * 40 + + +def _workflow(name: str) -> dict: + value = yaml.load( + (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8"), + Loader=yaml.BaseLoader, + ) + assert isinstance(value, dict) + return value + + +def test_preflight_requires_an_unpublished_current_main_candidate() -> None: + assert ( + release.preflight_failures( + version="1.2.3", + source_version="1.2.3", + candidate_commit=COMMIT, + workflow_commit=COMMIT, + checkout_commit=COMMIT, + main_commit=COMMIT, + tag_exists=False, + release_exists=False, + pypi_exists=False, + ) + == [] + ) + + failures = release.preflight_failures( + version="v1.2", + source_version="1.2.2", + candidate_commit="HEAD", + workflow_commit="b" * 40, + checkout_commit="c" * 40, + main_commit="d" * 40, + tag_exists=True, + release_exists=True, + pypi_exists=True, + ) + + assert len(failures) == 9 + assert "candidate_commit must be a full lowercase commit SHA" in failures + assert "PyPI version already exists: v1.2" in failures + + +def test_pypi_publication_must_match_candidate_metadata_and_bytes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + metadata = { + "author_email": "Stefan Jansen ", + "classifiers": ["Programming Language :: Python :: 3"], + "description": "Finance-specific models for asset pricing, prediction, and portfolio learning.", + "keywords": ["finance"], + "license": "MIT", + "maintainer_email": "Stefan Jansen ", + "project_urls": {"Homepage": "https://www.ml4trading.io/"}, + "requires_python": ">=3.12", + } + manifest = { + "name": "ml4t-models", + "version": "1.2.3", + "metadata": metadata, + "artifacts": [ + {"filename": "package.whl", "sha256": "b" * 64, "size": 10}, + {"filename": "package.tar.gz", "sha256": "c" * 64, "size": 20}, + ], + } + (tmp_path / "candidate.json").write_text(json.dumps(manifest), encoding="utf-8") + response = { + "info": { + "name": "ml4t-models", + "version": "1.2.3", + "author_email": metadata["author_email"], + "classifiers": metadata["classifiers"], + "summary": metadata["description"], + "keywords": "finance", + "license": "MIT", + "license_expression": None, + "maintainer_email": metadata["maintainer_email"], + "project_urls": metadata["project_urls"], + "requires_python": metadata["requires_python"], + }, + "urls": [ + { + "filename": artifact["filename"], + "digests": {"sha256": artifact["sha256"]}, + "size": artifact["size"], + } + for artifact in manifest["artifacts"] + ], + } + monkeypatch.setattr(release, "_package", lambda _name, _version: response) + + release.verify_publication(tmp_path) + response["urls"][0]["digests"]["sha256"] = "0" * 64 + with pytest.raises(ValueError, match="artifacts do not match"): + release.verify_publication(tmp_path) + + +def test_published_install_verification_retries_index_propagation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + results = iter( + ( + subprocess.CompletedProcess([], 1), + subprocess.CompletedProcess([], 1), + subprocess.CompletedProcess([], 0), + ) + ) + commands: list[list[str]] = [] + sleeps: list[int] = [] + + def run(command: list[str], *, check: bool) -> subprocess.CompletedProcess[bytes]: + assert check is False + commands.append(command) + return next(results) + + monkeypatch.setattr(release.subprocess, "run", run) + monkeypatch.setattr(release.time, "sleep", sleeps.append) + release.verify_install( + "ml4t-models", + "1.2.3", + Path("scripts/ci/readme_smoke.py"), + attempts=3, + retry_seconds=7, + ) + + assert len(commands) == 3 + assert "ml4t-models==1.2.3" in commands[0] + assert commands[0][-2:] == ["--expected-version", "1.2.3"] + assert sleeps == [7, 7] + + +def test_readme_quick_start_is_an_executable_installed_package_contract() -> None: + readme = ROOT / "README.md" + source = readme_smoke.extract_quick_start(readme.read_text(encoding="utf-8")) + assert "IPCAModel" in source + readme_smoke.run(readme, __version__) + + +def test_deployed_docs_default_retry_window_is_four_minutes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts: list[int] = [] + sleeps: list[float] = [] + + def read_identity(_url: str, _commit: str, attempt: int) -> object: + attempts.append(attempt) + return {"commit": "stale"} + + monkeypatch.setattr(verify_docs_deployment, "_read_identity", read_identity) + monkeypatch.setattr(verify_docs_deployment.time, "sleep", sleeps.append) + + with pytest.raises(RuntimeError, match="deployed documentation identity did not match"): + verify_docs_deployment.verify(("https://example.test/release.json",), {"commit": COMMIT}) + + assert attempts == list(range(24)) + assert sleeps == [10] * 23 + + +def test_release_workflow_reuses_one_commit_bound_candidate() -> None: + ci = _workflow("ci.yml") + release_workflow = _workflow("release.yml") + jobs = release_workflow["jobs"] + + assert "workflow_call" in ci["on"] + assert ci["on"]["workflow_call"]["inputs"]["release_version"]["type"] == "string" + assert set(release_workflow["on"]) == {"workflow_dispatch"} + assert set(release_workflow["on"]["workflow_dispatch"]["inputs"]) == { + "version", + "candidate_commit", + } + assert jobs["qualification"]["uses"] == "./.github/workflows/ci.yml" + assert jobs["qualification"]["with"]["release_version"] == ( + "${{ needs.validate.outputs.version }}" + ) + assert jobs["select-candidate"]["needs"] == [ + "validate", + "ecosystem-qualification", + "qualification", + ] + assert jobs["docs"]["needs"] == ["validate", "select-candidate"] + assert jobs["publish"]["needs"] == [ + "validate", + "ecosystem-qualification", + "select-candidate", + "docs", + ] + assert jobs["github-release"]["needs"] == ["validate", "publish"] + assert jobs["verify-release"]["needs"] == ["validate", "github-release"] + + workflow_text = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + assert "uv build" not in workflow_text + assert '--target "$CANDIDATE_COMMIT"' in workflow_text + assert "release.py verify candidate" in workflow_text + assert "release.py smoke-test" in workflow_text + assert "candidate.py verify released" in workflow_text + assert "verify_docs_deployment.py" in workflow_text + assert "needs.publish.result != 'skipped'" in workflow_text + assert "Rerun only failed jobs" in workflow_text + assert "Do not rebuild or reuse this version" in workflow_text + + +def test_only_release_workflow_can_deploy_documentation() -> None: + for name in ("ci.yml", "ecosystem.yml"): + assert "push-to-another-repository" not in ( + ROOT / ".github" / "workflows" / name + ).read_text(encoding="utf-8") + + +def test_ci_uses_locked_dependencies_and_requires_cuda_for_releases() -> None: + workflows = [ + (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + for name in ("ci.yml", "release.yml") + ] + ci = workflows[0] + assert 'version: "latest"' not in ci + assert 'UV_NO_SOURCES: "1"' in ci + for workflow in workflows: + sync_commands = [line for line in workflow.splitlines() if "uv sync" in line] + assert sync_commands + assert all("--locked" in command for command in sync_commands) + assert all("--frozen" not in command for command in sync_commands) + assert "inputs.release_version != ''" in ci + assert 'test "$CUDA_RESULT" = success' in ci + + +def test_uv_locked_sync_supports_the_no_sources_policy() -> None: + environment = {**os.environ, "UV_NO_SOURCES": "1"} + result = subprocess.run( + ["uv", "sync", "--locked", "--dev", "--dry-run"], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 29812fe..961926c 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -51,7 +51,7 @@ def test_workflow_actions_are_pinned_to_commits() -> None: line.strip() for workflow in workflows for line in workflow.read_text(encoding="utf-8").splitlines() - if "uses:" in line + if "uses:" in line and "uses: ./" not in line ] assert action_references @@ -140,14 +140,14 @@ def test_release_promotes_qualified_candidate_without_rebuilding() -> None: verifier = (root / "scripts/ci/verify_docs_deployment.py").read_text(encoding="utf-8") assert "uv build" not in workflow - assert "--workflow ci.yml" in workflow - assert '--expected-tag "${{ github.ref_name }}"' in workflow + assert "uses: ./.github/workflows/ci.yml" in workflow + assert '--expected-tag "v${{ needs.validate.outputs.version }}"' in workflow assert "packages-dir: candidate/dist/" in workflow assert "name: Publish Qualified Documentation" in workflow - assert "DOCS_DEPLOY_KEY is required for a stable release" in workflow + assert 'run: test -n "$DOCS_DEPLOY_KEY"' in workflow assert "scripts/ci/verify_docs_deployment.py" in workflow - assert "https://ml4trading.io/docs/models/release.json" in verifier - assert "needs: [ecosystem-qualification, select-candidate, docs]" in workflow + assert "https://www.ml4trading.io/docs/models/release.json" in verifier + assert "needs: [validate, ecosystem-qualification, select-candidate, docs]" in workflow assert '--repo "${{ github.repository }}"' in workflow