diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..72f818b --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,124 @@ +name: Create GitHub Release (manual) + +on: + workflow_dispatch: + inputs: + confirm: + description: "Confirm create release" + required: true + type: boolean + +permissions: {} + +jobs: + guard-ci-success: + runs-on: windows-2022 + permissions: + contents: read + statuses: read + actions: read + steps: + - name: Ensure CI for this commit succeeded + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ownerRepo = $env:GITHUB_REPOSITORY + $sha = $env:GITHUB_SHA + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $workflow = 'ci.yml' + $branch = $env:GITHUB_REF_NAME + $url = "https://api.github.com/repos/$ownerRepo/actions/workflows/$workflow/runs?branch=$branch&per_page=20" + + try { + $resp = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop + } catch { + Write-Error "Blocking release: failed to query CI workflow runs ($($_.Exception.Message))" + exit 1 + } + + if (-not $resp.workflow_runs) { + Write-Error "Blocking release: no CI runs found on branch '$branch'" + exit 1 + } + + $matching = $resp.workflow_runs | Where-Object { $_.head_sha -eq $sha -and $_.status -eq 'completed' } + if (-not $matching) { + Write-Error "Blocking release: no completed CI run found for commit $sha" + exit 1 + } + + $success = $matching | Where-Object { $_.conclusion -eq 'success' } | Select-Object -First 1 + if (-not $success) { + $concl = ($matching | Select-Object -First 1).conclusion + Write-Error "Blocking release: CI conclusion for $sha is '$concl'" + exit 1 + } + + build-and-release: + needs: guard-ci-success + if: ${{ github.event.inputs.confirm == 'true' && startsWith(github.ref_name, 'release/') }} + runs-on: windows-2022 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Check release tag does not already exist + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ownerRepo = $env:GITHUB_REPOSITORY + $versionJson = Get-Content -Raw -Path version.json | ConvertFrom-Json + $tag = "v$($versionJson.major).$($versionJson.minor).$($versionJson.patch)" + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $url = "https://api.github.com/repos/$ownerRepo/releases/tags/$tag" + try { + $release = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop + Write-Error "Release tag '$tag' already exists (id: $($release.id)). Bump version.json before creating a new release." + exit 1 + } catch { + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + if ($statusCode -eq 404) { + Write-Output "Release tag '$tag' not found — proceeding." + } else { + Write-Error "Failed to check for existing release tag '$tag': $($_.Exception.Message)" + exit 1 + } + } + + - name: Build package + run: python run.py build + + - name: Create GitHub Release + run: python run.py release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index db5cf62..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Publish package (manual) - -on: - workflow_dispatch: - inputs: - confirm: - description: "Confirm publish package" - required: true - type: boolean - docs_only: - description: "Only publish documentation (skip package publish)" - required: false - type: boolean - default: false - -jobs: - guard-ci-success: - runs-on: windows-2022 - permissions: - contents: read - statuses: read - actions: read - steps: - - name: Ensure CI for this commit succeeded - shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $ownerRepo = $env:GITHUB_REPOSITORY - $sha = $env:GITHUB_SHA - $headers = @{ - Authorization = "token $env:GITHUB_TOKEN" - Accept = 'application/vnd.github+json' - 'X-GitHub-Api-Version' = '2022-11-28' - } - - # Query specific CI workflow runs and require a successful run for this commit - $workflow = 'ci.yml' - $branch = $env:GITHUB_REF_NAME - $url = "https://api.github.com/repos/$ownerRepo/actions/workflows/$workflow/runs?branch=$branch&per_page=20" - - try { - $resp = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop - } catch { - Write-Error "Blocking publish: failed to query CI workflow runs ($($_.Exception.Message))" - exit 1 - } - - if (-not $resp.workflow_runs) { - Write-Error "Blocking publish: no CI runs found on branch '$branch'" - exit 1 - } - - $matching = $resp.workflow_runs | Where-Object { $_.head_sha -eq $sha -and $_.status -eq 'completed' } - if (-not $matching) { - Write-Error "Blocking publish: no completed CI run found for commit $sha" - exit 1 - } - - $success = $matching | Where-Object { $_.conclusion -eq 'success' } | Select-Object -First 1 - if (-not $success) { - $concl = ($matching | Select-Object -First 1).conclusion - Write-Error "Blocking publish: CI conclusion for $sha is '$concl'" - exit 1 - } - - build-and-publish: - needs: guard-ci-success - if: ${{ github.event.inputs.confirm == 'true' && startsWith(github.ref_name, 'release/') }} - runs-on: windows-2022 - permissions: - contents: write - pages: write - id-token: write - - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 # Fetch all history for sphinx-multiversion - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install dependencies for docs - if: ${{ github.event.inputs.docs_only == 'true' }} - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Check if version exists on PyPI - id: pypi_check - shell: pwsh - if: ${{ github.event.inputs.docs_only != 'true' }} - run: | - $packageName = 'moldflow' - - try { - $resp = Invoke-WebRequest -Uri "https://pypi.org/pypi/$packageName/json" -UseBasicParsing -ErrorAction Stop - $data = $resp.Content | ConvertFrom-Json - $versions = @($data.releases.PSObject.Properties.Name) - } catch { - $versions = @() - } - - $versionJson = Get-Content -Raw -Path version.json | ConvertFrom-Json - $version = "$($versionJson.major).$($versionJson.minor).$($versionJson.patch)" - $exists = if ($versions -contains $version) { 'true' } else { 'false' } - Write-Output "Release $version exists on PyPI: $exists" - "exists=$exists" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Skip publish, version already exists - if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'true' }} - run: Write-Output "Version ${{ steps.pypi_check.outputs.version }} already exists on PyPI. Skipping publish." - - - name: Install build dependencies - if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Build package - if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} - run: | - python run.py build - - - name: Publish to PyPI - if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: | - python run.py publish --skip-build - - - name: Create GitHub Release - if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} - run: | - python run.py release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure Pages - uses: actions/configure-pages@v5 - - - name: Fetch Git tags - run: git fetch --tags - - - name: Build documentation - run: python run.py build-docs - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: ./docs/build/html - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..9965e0f --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,291 @@ +name: Publish to PyPI (manual) + +on: + workflow_dispatch: + inputs: + tag: + description: "GitHub Release tag to publish (e.g. v27.1.2). Must already exist." + required: true + type: string + confirm: + description: "Confirm publish to PyPI" + required: true + type: boolean + docs_only: + description: "Only publish documentation (skip package publish)" + required: false + type: boolean + default: false + +permissions: {} + +jobs: + guard-ci-success: + runs-on: windows-2022 + permissions: + contents: read + statuses: read + actions: read + steps: + - name: Ensure CI for this commit succeeded + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ownerRepo = $env:GITHUB_REPOSITORY + $sha = $env:GITHUB_SHA + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $workflow = 'ci.yml' + $branch = $env:GITHUB_REF_NAME + $url = "https://api.github.com/repos/$ownerRepo/actions/workflows/$workflow/runs?branch=$branch&per_page=20" + + try { + $resp = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop + } catch { + Write-Error "Blocking publish: failed to query CI workflow runs ($($_.Exception.Message))" + exit 1 + } + + if (-not $resp.workflow_runs) { + Write-Error "Blocking publish: no CI runs found on branch '$branch'" + exit 1 + } + + $matching = $resp.workflow_runs | Where-Object { $_.head_sha -eq $sha -and $_.status -eq 'completed' } + if (-not $matching) { + Write-Error "Blocking publish: no completed CI run found for commit $sha" + exit 1 + } + + $success = $matching | Where-Object { $_.conclusion -eq 'success' } | Select-Object -First 1 + if (-not $success) { + $concl = ($matching | Select-Object -First 1).conclusion + Write-Error "Blocking publish: CI conclusion for $sha is '$concl'" + exit 1 + } + + publish: + needs: guard-ci-success + if: ${{ github.event.inputs.confirm == 'true' && startsWith(github.ref_name, 'release/') }} + runs-on: windows-2022 + permissions: + contents: read + pages: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Validate release tag exists + id: validate_tag + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $tag = "${{ github.event.inputs.tag }}" + $ownerRepo = $env:GITHUB_REPOSITORY + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $url = "https://api.github.com/repos/$ownerRepo/releases/tags/$tag" + try { + $release = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop + } catch { + Write-Error "Release tag '$tag' not found. Create a GitHub Release first using the 'Create GitHub Release' workflow." + exit 1 + } + + $version = $tag -replace '^v', '' + Write-Output "Release '$tag' found (id: $($release.id))" + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "release_id=$($release.id)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Check if version exists on PyPI + id: pypi_check + shell: pwsh + if: ${{ github.event.inputs.docs_only != 'true' }} + run: | + $packageName = 'moldflow' + $version = "${{ steps.validate_tag.outputs.version }}" + + try { + $resp = Invoke-WebRequest -Uri "https://pypi.org/pypi/$packageName/json" -UseBasicParsing -ErrorAction Stop + $data = $resp.Content | ConvertFrom-Json + $versions = @($data.releases.PSObject.Properties.Name) + } catch { + $versions = @() + } + + $exists = if ($versions -contains $version) { 'true' } else { 'false' } + Write-Output "Version $version exists on PyPI: $exists" + "exists=$exists" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Skip publish, version already exists + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'true' }} + run: | + Write-Output "Version ${{ steps.validate_tag.outputs.version }} already exists on PyPI. Skipping publish." + + - name: Check older releases are on PyPI + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $tag = "${{ github.event.inputs.tag }}" + $targetVersion = $tag -replace '^v', '' + $targetSemVer = [version]$targetVersion + $ownerRepo = $env:GITHUB_REPOSITORY + $packageName = 'moldflow' + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $releasesUrl = "https://api.github.com/repos/$ownerRepo/releases?per_page=100" + $releases = @(Invoke-RestMethod -Method Get -Uri $releasesUrl -Headers $headers -ErrorAction Stop) + + try { + $pypiResp = Invoke-WebRequest -Uri "https://pypi.org/pypi/$packageName/json" -UseBasicParsing -ErrorAction Stop + $pypiData = $pypiResp.Content | ConvertFrom-Json + $pypiVersions = @($pypiData.releases.PSObject.Properties.Name) + } catch { + Write-Error "Could not query PyPI to verify publish order. Aborting." + exit 1 + } + + $blocking = @() + foreach ($release in $releases) { + if ($release.draft -or $release.prerelease) { continue } + + $version = $release.tag_name -replace '^v', '' + try { + $releaseSemVer = [version]$version + } catch { + Write-Output "Skipping non-semver release tag: $($release.tag_name)" + continue + } + + if ($releaseSemVer -ge $targetSemVer) { continue } + if ($pypiVersions -contains $version) { continue } + + $blocking += $release.tag_name + } + + if ($blocking.Count -eq 0) { + Write-Output "All older GitHub releases are on PyPI. Proceeding with publish of $tag." + exit 0 + } + + Write-Output "" + Write-Output "=== UNPUBLISHED OLDER RELEASES ===" + Write-Output "Cannot publish $tag until these older releases are on PyPI:" + Write-Output "" + foreach ($item in ($blocking | Sort-Object)) { + Write-Output " - $item" + } + Write-Output "" + Write-Output "Publish the oldest missing version first." + Write-Output "" + + Write-Error "Found $($blocking.Count) older release(s) not on PyPI." + exit 1 + + - name: Download wheel from GitHub Release + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $tag = "${{ github.event.inputs.tag }}" + $version = "${{ steps.validate_tag.outputs.version }}" + $ownerRepo = $env:GITHUB_REPOSITORY + $authValue = "token $env:GITHUB_TOKEN" + $headers = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/octet-stream' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $apiHeaders = @{ + Authorization = $authValue #gitleaks:allow + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $url = "https://api.github.com/repos/$ownerRepo/releases/tags/$tag" + $release = Invoke-RestMethod -Method Get -Uri $url -Headers $apiHeaders -ErrorAction Stop + + New-Item -ItemType Directory -Path dist -Force | Out-Null + + $assets = $release.assets + if (-not $assets -or $assets.Count -eq 0) { + Write-Error "No assets found on release '$tag'. Ensure the release was created with the 'Create GitHub Release' workflow." + exit 1 + } + + foreach ($asset in $assets) { + $name = $asset.name + $downloadUrl = $asset.browser_download_url + Write-Output "Downloading: $name" + Invoke-WebRequest -Uri $downloadUrl -OutFile "dist/$name" -UseBasicParsing + } + + $whlFiles = Get-ChildItem -Path dist -Filter "*.whl" + if ($whlFiles.Count -eq 0) { + Write-Error "No .whl file found in release assets for '$tag'" + exit 1 + } + + Write-Output "Downloaded $($whlFiles.Count) wheel(s) to dist/" + + - name: Publish to PyPI + if: ${{ github.event.inputs.docs_only != 'true' && steps.pypi_check.outputs.exists == 'false' }} + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + python -m twine check --strict dist/* + python -m twine upload --verbose dist/* + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Fetch Git tags + run: git fetch --tags + + - name: Build documentation + run: python run.py build-docs + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./docs/build/html + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/RELEASE.md b/RELEASE.md index 10f0b0b..ab2b8dd 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,32 +1,22 @@ -# Release steps (minimal) +# Release steps -This file documents the minimal, explicit steps to bump the version and publish a release for moldflow-api. +This file documents how to create a release and publish to PyPI for moldflow-api. -Summary (short): +The process is split into two independent stages: +1. **Create a GitHub Release** (produces the wheel and attaches it to a tagged release) +2. **Publish to PyPI** (uploads the wheel from the GitHub Release to PyPI) -- Edit the root `version.json` (only this file). -- Commit on a branch named `release/MAJOR.MINOR.PATCH` and push. -- Wait for CI to pass on that branch. -- Trigger the manual "Publish package (manual)" workflow in GitHub Actions and confirm. +You can create the GitHub Release first and publish to PyPI when ready. -Why this works (important notes): +## Prerequisites -- The canonical version source for releases is the root `version.json` in the repository root. -- The `run.py` script (used for building and releasing locally and by many repo commands) - reads the `patch` value directly from the root `version.json` and will raise a - RuntimeError if `patch` is missing. -- The workflow only publishes when run on a branch whose name starts with `release/` and when - you confirm the manual workflow dispatch. -- The release workflow checks PyPI for an existing version and will skip publishing if that - exact version already exists. +- The canonical version source is the root `version.json` in the repository root. +- All release workflows only run on branches whose name starts with `release/`. +- CI (`ci.yml`) must pass before either workflow can proceed. -Minimal step-by-step +## Step 1: Bump the version -1. Decide the new major/minor/patch values. - - - Always set a numeric `patch` value in the root `version.json`. - -2. Edit `version.json` at the repository root. Example (bumping to MAJOR.MINOR.PATCH, e.g. 1.2.0): +1. Edit `version.json` at the repository root: ```json { @@ -36,74 +26,106 @@ Minimal step-by-step } ``` -3. Commit and push on a `release/` branch. Example (use the placeholder branch name below and replace with your version): +2. Commit and push on a `release/` branch: ```bash -# create branch using the target version (branch name must start with 'release/') -# use a placeholder like 'release/MAJOR.MINOR.PATCH' and replace with your values -git checkout -b release/MAJOR.MINOR.PATCH # e.g. release/1.2.0 +git checkout -b release/MAJOR # e.g. release/27 git add version.json -git commit -m "Bump version to MAJOR.MINOR.PATCH" # e.g. "Bump version to 1.2.0" -git push -u origin release/MAJOR.MINOR.PATCH +git commit -m "Bump version to MAJOR.MINOR.PATCH" +git push -u origin release/MAJOR ``` -4. Wait for CI to pass on that branch. +3. Wait for CI to pass on that branch. + +## Step 2: Create a GitHub Release - - The publish workflow has a guard that requires the `ci.yml` workflow to have completed - successfully for the same commit before allowing publish. +Trigger the **"Create GitHub Release (manual)"** workflow: -5. Trigger the publish workflow manually in the GitHub Actions UI for the repository. +- Open the workflow in GitHub Actions UI, choose `Run workflow` +- Set `confirm` to `true` +- Run on your `release/` branch + +Or via GitHub CLI: - - Open the `Publish package (manual)` workflow, choose `Run workflow`, set `confirm` to - `true`, and run it on your `release/MAJOR.MINOR.PATCH` branch (replace the placeholder - with the actual version). - - Alternatively, you can use the GitHub CLI (if you have it configured). Example using a - placeholder branch name (replace with your actual branch): ```bash -# example (replace with the correct workflow file name and branch if needed) -# gh workflow run publish.yml --ref release/MAJOR.MINOR.PATCH -f confirm=true -# e.g. --ref release/1.2.0 +gh workflow run github-release.yml --ref release/MAJOR -f confirm=true ``` -6. What the workflow does (high level): +This will: +- Ensure CI passed for the commit +- Build the package (`python run.py build`) +- Create a GitHub Release with tag `vMAJOR.MINOR.PATCH` +- Attach the wheel (`.whl`) and source distribution (`.tar.gz`) as release assets + +The wheel is now available from the GitHub Release assets. + +## Step 3: Publish to PyPI -- Ensures CI (`ci.yml`) passed for the commit. -- Computes the release version using `version.json`. -- If the computed version already exists on PyPI the workflow will skip the publish. -- If not present, it builds the package, uploads to PyPI (requires the repo to have - `PYPI_API_TOKEN` in secrets), creates a GitHub release (tag `vMAJOR.MINOR.PATCH`) and - deploys documentation to GitHub Pages. +When ready to make the package publicly available, trigger the **"Publish to PyPI (manual)"** workflow: -Local testing and notes +- Open the workflow in GitHub Actions UI, choose `Run workflow` +- Set `tag` to the GitHub Release tag (e.g. `v27.0.0`) +- Set `confirm` to `true` +- Run on your `release/` branch -- You can build the package locally to smoke test the build step: +Or via GitHub CLI: ```bash -python run.py build +gh workflow run pypi-publish.yml --ref release/MAJOR -f tag=v27.0.0 -f confirm=true ``` -- Publishing to PyPI is intentionally restricted to the manual GitHub Actions workflow. - If you need to test publishing to TestPyPI locally, you can use `python -m twine upload` - with TestPyPI credentials, but this is separate from the CI-based publish flow. +This will: +- Validate the release tag exists +- Check if the version already exists on PyPI (skip if it does) +- Download the wheel from the GitHub Release assets +- Upload to PyPI using `twine` +- Build and deploy documentation to GitHub Pages + +## PyPI publish ordering + +Creating a GitHub Release does **not** require older releases to be on PyPI — multiple +GitHub Releases can exist during development. + +When you run **Publish to PyPI**, the workflow checks that every **older** GitHub +Release (by version number) is already on PyPI before uploading the tag you selected. +Draft and prerelease GitHub Releases are ignored. + +Example — GitHub Releases: `v26.1.0`, `v27.0.0`, `v27.1.0`, `v27.1.1`; PyPI: `26.1.0`, `27.0.0`: -Edge cases and tips +| Publish tag | Result | +|---|---| +| `v27.1.0` | Allowed (older releases are on PyPI) | +| `v27.1.1` | **Blocked** (`v27.1.0` is older and not on PyPI) | -- `run.py` now requires a `patch` value in the root `version.json`. It will raise a - RuntimeError if that key is missing. Do not rely on `run.py` falling back to any - environment variable. -- If you want CI to inject a monotonic build number into the patch segment, make the CI - step explicitly update `version.json` (or generate a temporary `version.json`) with the - desired `patch` before running the build and publish steps. That keeps `run.py` and the - workflow in agreement. -- The `run.py` script will write a package-local `src/moldflow/version.json` at build time; - you do not need to edit that file directly (it is generated and typically ignored by Git). +Publish in order. If an intermediate build had issues, publish it to PyPI anyway and +follow with the fix — `pip install --upgrade` resolves to the latest version, so users +are not left on the bad release. + +## Local testing + +Build the package locally to smoke test: + +```bash +python run.py build +``` + +Publishing to PyPI is restricted to the GitHub Actions workflows. Use `--testpypi` +for local testing if needed: + +```bash +python run.py publish --testpypi +``` -Cleanup (optional) +## Notes -- After a successful release you may merge the `release/` branch to `main` (if you use merge - workflow) and delete the `release/` branch. +- `run.py` requires a `patch` value in the root `version.json`. It will raise a + RuntimeError if that key is missing. +- The `run.py` script writes a package-local `src/moldflow/version.json` at build + time; you do not need to edit that file directly. +- After a successful release and publish, you may merge the `release/` branch back + to `main` and delete the branch. -Contact +## Contact -If anything in CI behaves unexpectedly, check the logs for the `publish` workflow and the -`ci` workflow; feel free to open an issue or ask a maintainer. +If anything behaves unexpectedly, check the logs for the `github-release` and +`pypi-publish` workflows; feel free to open an issue or ask a maintainer.