diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0c12493 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,192 @@ +name: Publish to PyPI + +# Trusted Publishing (OIDC) — no long-lived API tokens stored. +# +# Triggers: +# - release.published on a non-prerelease GitHub Release +# → publishes to PyPI (environment: pypi) +# - release.published on a pre-release GitHub Release +# → publishes to TestPyPI (environment: testpypi) +# - workflow_dispatch with target=testpypi +# → publishes to TestPyPI (staging verification) +# - workflow_dispatch with target=build-only +# → builds + twine-checks, no network publish (dry run) +# +# One-time setup required before the publish jobs succeed: +# See docs/RELEASE.md for Trusted Publisher configuration on +# pypi.org and test.pypi.org. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + target: + description: "Publish target" + required: true + default: "build-only" + type: choice + options: + - build-only + - testpypi + +concurrency: + group: publish-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # ------------------------------------------------------------------ + # Build sdist + wheel, run twine check, run the full test suite + # against the built wheel so we never ship a wheel that fails tests. + # ------------------------------------------------------------------ + build: + name: Build distributions + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.version.outputs.value }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Extract version from package + id: version + run: | + v=$(python -c "import re, pathlib; print(re.search(r'^__version__\s*=\s*[\"\']([^\"\']+)', pathlib.Path('maithili_dsl/__init__.py').read_text(), re.M).group(1))") + echo "value=$v" >> "$GITHUB_OUTPUT" + echo "Package version: $v" + + - name: Build sdist + wheel + run: python -m build + + - name: twine check + run: twine check --strict dist/* + + - name: Install built wheel in clean venv + run: | + python -m venv /tmp/smoketest + /tmp/smoketest/bin/pip install dist/*.whl + /tmp/smoketest/bin/python -m maithili_dsl --version + /tmp/smoketest/bin/python_maithili examples/hello.dmai + + - name: Run tests against the installed wheel + run: | + /tmp/smoketest/bin/pip install -r requirements-dev.txt + /tmp/smoketest/bin/pytest -q --no-cov + + - name: Upload distributions artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + # ------------------------------------------------------------------ + # TestPyPI — fires on workflow_dispatch (target=testpypi) or on + # a pre-release GitHub Release. Safe to re-run with a different + # version number. + # ------------------------------------------------------------------ + publish-testpypi: + name: Publish to TestPyPI + needs: [build] + runs-on: ubuntu-latest + timeout-minutes: 10 + if: | + (github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi') || + (github.event_name == 'release' && github.event.release.prerelease == true) + environment: + name: testpypi + url: https://test.pypi.org/p/python-maithili + permissions: + id-token: write # OIDC token for Trusted Publishing + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: false + verbose: true + + - name: Summary + run: | + echo "### Published to TestPyPI :rocket:" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Version: **${{ needs.build.outputs.version }}**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`bash" >> "$GITHUB_STEP_SUMMARY" + echo "pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ python-maithili==${{ needs.build.outputs.version }}" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" + + # ------------------------------------------------------------------ + # Production PyPI — fires only on a non-prerelease GitHub Release. + # This is the single authoritative way to publish a real version. + # ------------------------------------------------------------------ + publish-pypi: + name: Publish to PyPI + needs: [build] + runs-on: ubuntu-latest + timeout-minutes: 10 + if: | + github.event_name == 'release' && github.event.release.prerelease == false + environment: + name: pypi + url: https://pypi.org/p/python-maithili + permissions: + id-token: write # OIDC token for Trusted Publishing + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Verify release tag matches package version + env: + TAG: ${{ github.event.release.tag_name }} + VERSION: ${{ needs.build.outputs.version }} + run: | + expected="v${VERSION}" + if [ "$TAG" != "$expected" ]; then + echo "::error::Release tag '$TAG' does not match package version '$expected'. Refusing to publish." + exit 1 + fi + echo "Tag $TAG matches package version $VERSION — proceeding." + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: false + verbose: true + + - name: Summary + run: | + echo "### Published to PyPI :tada:" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Version: **${{ needs.build.outputs.version }}**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`bash" >> "$GITHUB_STEP_SUMMARY" + echo "pip install python-maithili==${{ needs.build.outputs.version }}" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c4cc3..222e0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 your change here._ +### Added +- **PyPI publish pipeline** (`.github/workflows/publish.yml`) using + Trusted Publishing (OIDC). Triggers: GitHub Release for production + PyPI, pre-release or `workflow_dispatch` for TestPyPI, `build-only` + dispatch for a credential-free dry run. The workflow builds sdist + + wheel, runs `twine check --strict`, installs the wheel in a clean + venv, runs the full pytest suite against the installed wheel, and + verifies the release tag matches `__version__` before publishing. +- **`docs/RELEASE.md`** — step-by-step release process including + one-time Trusted Publisher setup on PyPI + TestPyPI, routine + release flow, version-bump convention, and recovery procedure for + bad releases. +- **`build` and `twine`** added to `requirements-dev.txt`. ## [0.3.0] — 2026-04-17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a9c50a..ea79560 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,4 +87,12 @@ import whitelist (`MAITHILI_MODULES`), or the safe-builtins list 4. Open a Pull Request targeting `development`. The PR template will prompt you for test evidence and any security considerations. +## 📦 Releasing + +Releases are cut by a project maintainer following the checklist in +[`docs/RELEASE.md`](docs/RELEASE.md). The short version: +publishing is triggered by creating a **GitHub Release** and uses +**Trusted Publishing (OIDC)** — no long-lived PyPI tokens are stored +in the repo. + Thanks for your interest! ❤️ diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..3c15485 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,163 @@ +# Release Process + +This document describes how to cut a new release of `python_maithili` +to PyPI. The workflow is **`.github/workflows/publish.yml`** and uses +**Trusted Publishing (OIDC)** — no API tokens are stored in GitHub. + +--- + +## One-time setup (required before the first publish succeeds) + +### 1. Create GitHub Environments + +In the repo, go to **Settings → Environments** and create two +environments — they give us a place to pin the OIDC trust and (for +production) an optional manual-approval gate. + +#### `testpypi` +- No required reviewers. +- Deployment branches: all branches (we publish to TestPyPI from + feature branches too). + +#### `pypi` +- **Required reviewers**: add yourself. Every production publish will + wait for explicit approval. This is the final safety gate. +- Deployment branches: protected tags matching `v*` only + (`v[0-9]+.[0-9]+.[0-9]+` and `v[0-9]+.[0-9]+.[0-9]+-*`). + +### 2. Register the Trusted Publisher on PyPI + +Go to +(owner only) and add a new "Trusted publisher": + +| Field | Value | +|---------------------|--------------------------------| +| Owner | `alphacrack` | +| Repository | `python-maithili-dsl` | +| Workflow filename | `publish.yml` | +| Environment name | `pypi` | + +### 3. Register the Trusted Publisher on TestPyPI + +TestPyPI doesn't have the project yet, so use a **pending publisher**. +Go to and under +"Pending trusted publishers" add: + +| Field | Value | +|---------------------|--------------------------------| +| PyPI Project Name | `python-maithili` | +| Owner | `alphacrack` | +| Repository | `python-maithili-dsl` | +| Workflow filename | `publish.yml` | +| Environment name | `testpypi` | + +The pending publisher converts to a real one the first time a workflow +run successfully creates the project on TestPyPI. + +--- + +## Routine release workflow + +### A. Dry-run build (no publish) + +Push a PR or run **Actions → Publish to PyPI → Run workflow → `build-only`**. +This executes the full pipeline except the publish step: + +- Build sdist + wheel. +- `twine check --strict` metadata validation. +- Install the wheel in a clean venv. +- Run the full pytest suite against the installed wheel. +- Upload the `dist/` artifact for inspection. + +No credentials are required and nothing touches the network package +indexes. Use this whenever you want to confirm the package will build. + +### B. Publish to TestPyPI (staging verification) + +Two ways to trigger: + +1. **Manual**: Actions → Publish to PyPI → Run workflow → `testpypi`. +2. **Automatic**: create a GitHub **pre-release** (check "This is a + pre-release" when making the Release). Tag format: `v0.X.Y-rc1`, + `v0.X.Y-dev1`, etc. + +Version numbers you publish to TestPyPI are **separate** from +production PyPI — TestPyPI has its own index. Verify the install: + +```bash +pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + python-maithili==0.X.Y +python_maithili --version +``` + +Any version on TestPyPI cannot be re-used. If you need to iterate, +bump the version (`0.3.0.dev0`, `0.3.0.dev1`, ...) in +`maithili_dsl/__init__.py` before re-triggering. + +### C. Publish to production PyPI + +**Pre-flight checklist:** + +- [ ] `CHANGELOG.md` has a section for the new version with date filled in. +- [ ] `maithili_dsl/__init__.py` has `__version__` set to the new version. +- [ ] `pyproject.toml` has matching `version = "..."`. +- [ ] `pytest` runs green on `main` CI. +- [ ] The same version was successfully published to TestPyPI and installed + cleanly (step B). + +**Cut the release:** + +1. Ensure `main` has the final release commit on it (all three version + strings updated, CHANGELOG finalized). +2. Create a new **GitHub Release** (not a pre-release): + - Tag: `v0.X.Y` (must match `__version__` exactly; the workflow + enforces this and refuses to publish on mismatch). + - Target: `main`. + - Title: `v0.X.Y`. + - Body: the corresponding CHANGELOG section. + - Leave "This is a pre-release" **unchecked**. +3. Click **Publish release**. The workflow fires. +4. If the `pypi` environment has required reviewers, approve the + deployment in the Actions run. +5. After the run finishes, verify: + + ```bash + pip install python-maithili==0.X.Y + python_maithili --version + ``` + +--- + +## Version bump convention + +This project uses [Semantic Versioning](https://semver.org/): + +| Change | Bump | +|------------------------------------------------|-------| +| Breaking API change (exit code semantics, etc) | MAJOR | +| New keyword / module / feature | MINOR | +| Bugfix, doc change, internal refactor | PATCH | + +Version is tracked in three places that must stay in sync: + +1. `maithili_dsl/__init__.py` — `__version__` (single source of truth). +2. `pyproject.toml` — `version`. +3. `CHANGELOG.md` — section header and date. + +The publish workflow's "Verify release tag matches package version" +step will fail the production publish if these drift. + +--- + +## Recovery: what if a bad version reaches PyPI? + +PyPI does not allow re-uploading the same filename. If you published +a broken release: + +1. **Yank** (do not delete) the broken version on PyPI: + → + pick the version → "Yank release". This hides it from `pip install` + without breaking already-pinned consumers. +2. Bump PATCH and release the fix via the normal flow above. diff --git a/requirements-dev.txt b/requirements-dev.txt index 3a4df92..d3b271c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,7 @@ pytest>=7.4,<9 pytest-cov>=4.1,<6 pytest-timeout>=2.2,<3 + +# Build + release tooling (used by docs/RELEASE.md workflow). +build>=1.0,<2 +twine>=4.0,<6