diff --git a/.github/workflows/examples-artifact.yml b/.github/workflows/examples-artifact.yml new file mode 100644 index 0000000..2ac9d79 --- /dev/null +++ b/.github/workflows/examples-artifact.yml @@ -0,0 +1,41 @@ +name: Notebook examples zip archive + +on: + workflow_call: + +permissions: + contents: read + +jobs: + examples-artifact: + + strategy: + matrix: + os: [ubuntu-latest] + python: ["3.11"] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install tooling + run: | + python -m pip install --upgrade pip + - name: Install dependencies + run: | + python -m pip install jupyter-client~=8.6 jupytext~=1.16 + - name: Convert notebooks + run: | + python -m jupytext --opt notebook_metadata_filter=-kernelspec \ + --to ipynb docs/source/examples/*.md + - name: Archive notebook and data artifact + uses: actions/upload-artifact@v4 + with: + name: notebook-examples + path: | + docs/source/examples/*.ipynb + docs/source/examples/data diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..80fe92a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,32 @@ +# Regular QA workflow for main branch + +name: Test + +on: + push: + branches: + - main + +jobs: + code-quality: + uses: ./.github/workflows/code-quality.yml + unit-tests: + uses: ./.github/workflows/unit-tests.yml + needs: [code-quality] + notebook-examples: + uses: ./.github/workflows/notebook-examples.yml + needs: [code-quality] + examples-artifact: + uses: ./.github/workflows/examples-artifact.yml + needs: [code-quality] + trigger-rtd-build: + runs-on: ubuntu-latest + needs: [examples-artifact] + steps: + - name: Trigger RTD build + env: + RTD_HOOK_TOKEN: ${{ secrets.RTD_HOOK_TOKEN }} + run: | + curl -X POST -d "branches=main" -d "token=$RTD_HOOK_TOKEN" \ + -d "default_branch=main" \ + https://readthedocs.com/api/v2/webhook/gurobi-optimization-gurobipy-pandas/11030/ diff --git a/.github/workflows/test.yml b/.github/workflows/pr.yml similarity index 64% rename from .github/workflows/test.yml rename to .github/workflows/pr.yml index 92eebce..c140992 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/pr.yml @@ -1,11 +1,8 @@ -# Regular QA workflow (pull requests, main branch) +# QA workflow for pull requests (no RTD trigger) -name: Test +name: Pull request on: - push: - branches: - - main pull_request_target: branches: - main @@ -19,3 +16,6 @@ jobs: notebook-examples: uses: ./.github/workflows/notebook-examples.yml needs: [unit-tests] + examples-artifact: + uses: ./.github/workflows/examples-artifact.yml + needs: [notebook-examples] diff --git a/.gitignore b/.gitignore index f0efd5c..9f8c0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ docs/source/examples/*.ipynb # Vim swap files *.swp + +# Downloaded zip artifact +docs/source/artifact/*.zip diff --git a/.readthedocs.yaml b/.readthedocs.yaml index c7299ce..85996c7 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,6 +4,9 @@ build: os: "ubuntu-22.04" tools: python: "3.11" + jobs: + pre_build: + - python scripts/artifacts.py sphinx: fail_on_warning: true diff --git a/docs/source/conf.py b/docs/source/conf.py index 1891dcb..3d80f16 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -81,3 +81,14 @@ # -- Options for EPUB output epub_show_urls = "footnote" + +# -- Note pointing to notebook downloads + +nbsphinx_prolog = """ + +.. note:: + + This is example is available as a Jupyter notebook. Download it and all + necessary data files :download:`here `. + +""" diff --git a/scripts/artifacts.py b/scripts/artifacts.py new file mode 100644 index 0000000..8d6edee --- /dev/null +++ b/scripts/artifacts.py @@ -0,0 +1,90 @@ +# Run as a pre-build step on RTD. Use a fine-grained personal access token +# with no extra permissions (for public repos). +# +# Local test: +# +# READTHEDOCS_GIT_COMMIT_HASH=$(git rev-parse HEAD) READTHEDOCS=True \ +# GH_API_TOKEN= python artifacts.py + +import os +import pathlib +import sys + +import requests + +docs_source = pathlib.Path(__file__).parent.parent.joinpath("docs/source").resolve() +target = docs_source.joinpath("artifact/gurobipy-pandas-examples.zip") + + +def download_executed_notebooks(runs_url, gh_token, head_sha): + headers = { + "X-GitHub-Api-Version": "2022-11-28", + "Authorization": f"Bearer {gh_token}", + } + + params = {"head_sha": head_sha} + response = requests.get(runs_url, headers=headers, params=params) + response.raise_for_status() + runs_data = response.json() + + for run in runs_data["workflow_runs"]: + print("Run id={id} event={event} status={status} path={path}".format(**run)) + + if run["path"] != ".github/workflows/test.yml": + continue + if run["status"] != "completed": + continue + if run["conclusion"] != "success": + continue + + artifacts_url = run["artifacts_url"] + response = requests.get(artifacts_url, headers=headers) + response.raise_for_status() + artifacts_data = response.json() + + for artifact in artifacts_data["artifacts"]: + print("Artifact id={id} name={name}".format(**artifact)) + + if artifact["name"] != "notebook-examples": + continue + + download_url = artifact["archive_download_url"] + response = requests.get(download_url, headers=headers) + response.raise_for_status() + + os.makedirs(target.parent, exist_ok=True) + if target.exists(): + target.unlink() + with target.open("wb") as outfile: + outfile.write(response.content) + + print(f"Downloaded {target}") + return True + + return False + + +if not os.environ.get("GH_API_TOKEN"): + # Pull requests run in this configuration + print("No API token, can't fetch artifacts. Continuing build with dummy file.") + os.makedirs(target.parent, exist_ok=True) + with target.open("wb") as outfile: + outfile.write(b"") + sys.exit(0) + +success = download_executed_notebooks( + runs_url="https://api.github.com/repos/Gurobi/gurobipy-pandas/actions/runs", + gh_token=os.environ["GH_API_TOKEN"], + head_sha=os.environ["READTHEDOCS_GIT_COMMIT_HASH"], +) + +if success: + # Success, RTD build can continue + print("Artifact retrieval succeeded.") + sys.exit(0) +else: + # Cancels the RTD build (rely on a later trigger to rebuild) + print("Aritfact not found. Cancelling build.") + sys.exit(183) + +# Any exception would be a build failure