Add spatial early warning signals (Moran's I) and Empirical Brown's Method p-value combiner #11
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Builds on every PR; publishes only on a version tag. | |
| # | |
| # Uses PyPI Trusted Publishing (OIDC) — NO API token is stored anywhere. GitHub | |
| # mints a short-lived identity token scoped to this workflow in this repository, | |
| # and PyPI verifies it. Nothing long-lived exists to leak, and a stolen | |
| # credential cannot be replayed from anywhere else. | |
| # | |
| # ── ONE-TIME SETUP (PyPI project Owner only) ──────────────────────────────── | |
| # https://pypi.org/manage/project/ewstools/settings/publishing/ | |
| # Owner : ThomasMBury | |
| # Repository name : ewstools | |
| # Workflow filename : release.yml | |
| # Environment name : pypi | |
| # | |
| # Optional dry-run target, same form at https://test.pypi.org (separate account): | |
| # Environment name : testpypi | |
| # | |
| # ── HOW TO RELEASE ────────────────────────────────────────────────────────── | |
| # 0. (optional, recommended first time) Actions ▸ Release ▸ Run workflow | |
| # ▸ target = testpypi — proves the whole path without touching real PyPI | |
| # 1. bump `version` in pyproject.toml, land it on main | |
| # 2. git tag v2.1.3 && git push origin v2.1.3 | |
| # | |
| # The tag must match pyproject.toml exactly; the build job fails loudly if not, | |
| # BEFORE anything is published. | |
| # | |
| # ── IF THE DOCS GATE BLOCKS A RELEASE ─────────────────────────────────────── | |
| # A tagged release also requires that Read the Docs built successfully. If RTD | |
| # is down, slow, or wrong, that gate would otherwise strand a release, so there | |
| # is a deliberate way past it: | |
| # | |
| # Actions ▸ Release ▸ Run workflow ▸ target = pypi ▸ skip_docs_gate = true | |
| # | |
| # That publishes the current branch state without waiting on RTD. It is the | |
| # escape hatch, and it is meant to be used when RTD is the thing that is broken | |
| # -- not as a way to ship known-broken docs. | |
| on: | |
| push: | |
| tags: ['v*'] | |
| pull_request: # build + metadata check only, never publishes | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: 'Where to publish (dry-run defaults to nowhere)' | |
| required: true | |
| default: 'none' | |
| type: choice | |
| options: ['none', 'testpypi', 'pypi'] | |
| skip_docs_gate: | |
| description: 'Publish even if Read the Docs is red (use when RTD is what is broken)' | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| # One release at a time. Prevents a double-pushed tag, or a tag plus a manual | |
| # dispatch, from racing two uploads at the same artefact. | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| build: | |
| name: Build and check distributions | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' # >=3.11 required: tomllib is stdlib there | |
| # The classic release failure is a tag that disagrees with the packaged | |
| # version: you tag v2.1.3, ship 2.1.2, and PyPI either rejects it as a | |
| # duplicate or accepts it under the wrong number. Catch it before build. | |
| - name: Tag must match pyproject version | |
| if: startsWith(github.ref, 'refs/tags/') | |
| run: | | |
| TAG="${GITHUB_REF_NAME#v}" | |
| PKG="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")" | |
| echo "tag=$TAG pyproject=$PKG" | |
| if [ "$TAG" != "$PKG" ]; then | |
| echo "::error::tag v$TAG does not match pyproject.toml version $PKG" | |
| exit 1 | |
| fi | |
| # 2.1.3 shipped while the Read the Docs build was red: RTD had failed on | |
| # the three preceding pushes to main, and the `stable` build triggered by | |
| # the tag itself failed one minute before the upload. GitHub Actions was | |
| # green throughout, because RTD is a separate build system with separate | |
| # failure modes. Green CI is not a green project, so ask RTD directly. | |
| # | |
| # Read-only and unauthenticated -- RTD exposes build state for public | |
| # projects without a token. That is deliberate: this workflow stores no | |
| # long-lived credentials and should not start now. | |
| - name: Read the Docs must be green | |
| if: >- | |
| (startsWith(github.ref, 'refs/tags/') | |
| || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')) | |
| && inputs.skip_docs_gate != true | |
| env: | |
| RTD_PROJECT: ewstools | |
| # A tag makes RTD build `stable`, so wait for that build to appear. | |
| # A manual dispatch triggers no RTD build, so check `latest`, which | |
| # tracks main, and take it as-is. | |
| RTD_VERSION: ${{ startsWith(github.ref, 'refs/tags/') && 'stable' || 'latest' }} | |
| RTD_REQUIRE_FRESH: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} | |
| run: | | |
| python - <<'PY' | |
| import json, os, sys, time, urllib.request | |
| from datetime import datetime, timezone | |
| project = os.environ["RTD_PROJECT"] | |
| want = os.environ["RTD_VERSION"] | |
| require_fresh = os.environ["RTD_REQUIRE_FRESH"] == "true" | |
| api = f"https://readthedocs.org/api/v3/projects/{project}/builds/?limit=20" | |
| # Do NOT key this on the build's `commit`: RTD records an EMPTY commit | |
| # string for builds that fail before checkout, which is exactly the | |
| # case this gate exists to catch. Match on newest-build-for-`want` | |
| # plus recency instead. | |
| deadline = time.time() + 15 * 60 | |
| fresh_minutes = 30 | |
| build_url = f"https://app.readthedocs.org/projects/{project}/builds" | |
| escape = ("If RTD itself is the problem, publish via " | |
| "Actions > Release > Run workflow with target=pypi and " | |
| "skip_docs_gate=true.") | |
| def newest(): | |
| with urllib.request.urlopen(api, timeout=30) as r: | |
| for b in json.load(r)["results"]: | |
| if b.get("version") == want: | |
| return b | |
| return None | |
| while True: | |
| try: | |
| b = newest() | |
| except Exception as exc: # transient: retry until deadline | |
| b, why = None, f"RTD API unreachable ({exc})" | |
| else: | |
| why = f"no {want} build found yet" | |
| if b is not None: | |
| created = datetime.fromisoformat(b["created"].replace("Z", "+00:00")) | |
| age = (datetime.now(timezone.utc) - created).total_seconds() / 60 | |
| finished = (b.get("state") or {}).get("code") == "finished" | |
| print(f"newest {want} build: id={b.get('id')} " | |
| f"state={(b.get('state') or {}).get('code')} " | |
| f"success={b.get('success')} age={age:.1f}min") | |
| if finished and not (require_fresh and age > fresh_minutes): | |
| if b.get("success"): | |
| print(f"::notice::Read the Docs {want} build {b['id']} succeeded") | |
| sys.exit(0) | |
| sys.exit(f"::error::Read the Docs {want} build {b['id']} FAILED " | |
| f"-- {build_url}/{b['id']}/ . Fix the docs build. {escape}") | |
| why = ("still building" if not finished else | |
| f"newest {want} build is {age:.0f}min old; waiting for this release's build") | |
| if time.time() > deadline: | |
| sys.exit(f"::error::Timed out after 15 min: {why}. {build_url}/ . {escape}") | |
| print(f"waiting: {why}") | |
| time.sleep(30) | |
| PY | |
| - name: Build sdist and wheel | |
| run: | | |
| python -m pip install --upgrade pip build twine | |
| python -m build | |
| # Catches broken long_description / bad classifiers BEFORE upload. PyPI | |
| # rejects those at upload time, which on a tag means a failed release. | |
| - name: Check metadata renders on PyPI | |
| run: python -m twine check --strict dist/* | |
| - name: Confirm the built artefact imports | |
| run: | | |
| python -m pip install dist/*.whl | |
| python -c "import ewstools; print('import OK:', ewstools.__file__)" | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist | |
| path: dist/ | |
| publish-testpypi: | |
| name: Publish to TestPyPI (dry run) | |
| needs: build | |
| # Never publish from a fork: a `v*` tag pushed on any fork would otherwise | |
| # reach the trusted-publishing handshake before failing. | |
| if: github.repository == 'ThomasMBury/ewstools' && github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: testpypi | |
| url: https://test.pypi.org/p/ewstools | |
| permissions: | |
| id-token: write | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: {name: dist, path: dist/} | |
| - uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| repository-url: https://test.pypi.org/legacy/ | |
| skip-existing: true | |
| publish-pypi: | |
| name: Publish to PyPI | |
| needs: build | |
| # Tags publish automatically; a manual dispatch must explicitly ask for pypi. | |
| if: github.repository == 'ThomasMBury/ewstools' && (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')) | |
| runs-on: ubuntu-latest | |
| # Named environment so a human approval can be required later (Settings ▸ | |
| # Environments ▸ pypi ▸ required reviewers) without editing this file. | |
| environment: | |
| name: pypi | |
| url: https://pypi.org/p/ewstools | |
| permissions: | |
| id-token: write # REQUIRED for trusted publishing; nothing else is | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: {name: dist, path: dist/} | |
| - uses: pypa/gh-action-pypi-publish@release/v1 |