From 544c4e9faeb98585edb8d6d8cb429bfab60ac8e9 Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Tue, 19 May 2026 14:31:34 -0300 Subject: [PATCH 1/6] Fix cache poisoning vulnerability in GitHub Actions Zizmor raised an error because of [cache poisoning](https://docs.zizmor.sh/audits/#cache-poisoning), which is where an atacker can hide a malicious payload to cached data and gain access to other workflows when the cache is restored. This could be used to corrupt a release artifact when the release workflow uses caching. We did this in our docs workflow. We also uploaded artifacts of the docs and PyPI distributions and then restored them. Seems like this should also be vulnerable to similar atacks. To avoid all of this, redo the way we deploy in Actions. Add a separate `publish` workflow that only runs on pushes to `main` and releases. It builds the docs and wheels, then publishes them to GitHub pages and PyPI/TestPyPI all in the same job without using caching or artifacts. The `docs` workflow only builds the docs and doesn't deploy them anymore. The `pypi` workflow was removed, with the check of the built packages living in the `test` workflow now. --- .github/workflows/docs.yml | 108 +-------------------- .github/workflows/publish.yml | 173 ++++++++++++++++++++++++++++++++++ .github/workflows/pypi.yml | 128 ------------------------- .github/workflows/test.yml | 20 ++-- 4 files changed, 187 insertions(+), 242 deletions(-) create mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/pypi.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 608fa6fad..e57378c2f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,4 @@ -# Build the documentation and deploy to GitHub Pages using GitHub Actions. +# Build the documentation to check that it works without errors. # # NOTE: Pin actions to a specific commit to avoid having the authentication # token stolen if the Action is compromised. See the comments and links here: @@ -6,17 +6,11 @@ # name: documentation -# Only build PRs, the main branch, and releases. Pushes to branches will only -# be built when a PR is opened. This avoids duplicated buids in PRs comming -# from branches in the origin repository (1 for PR and 1 for push). on: pull_request: push: branches: - main - release: - types: - - published permissions: {} @@ -77,11 +71,7 @@ jobs: run: conda list - name: Build source and wheel distributions - run: | - make build - echo "" - echo "Generated files:" - ls -lh dist/ + run: make build - name: Install the package run: python -m pip install --no-deps dist/*.whl @@ -98,97 +88,3 @@ jobs: sleep 3 # Build the docs make -C doc clean all - - # Store the docs as a build artifact so we can deploy it later - - name: Upload HTML documentation as an artifact - if: success() - uses: actions/upload-artifact@v7 - with: - name: docs-${{ github.sha }} - path: doc/_build/html - - ############################################################################# - # Publish the documentation to gh-pages - publish: - runs-on: ubuntu-latest - needs: build - permissions: - contents: write - if: github.event_name == 'release' || github.event_name == 'push' - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # The GitHub token is preserved by default but this job doesn't need - # to be able to push to GitHub. - persist-credentials: false - - # Fetch the built docs from the "build" job - - name: Download HTML documentation artifact - uses: actions/download-artifact@v8 - with: - name: docs-${{ github.sha }} - path: doc/_build/html - - - name: Checkout the gh-pages branch in a separate folder - uses: actions/checkout@v6 - with: - ref: gh-pages - # Checkout to this folder instead of the current one - path: deploy - # Download the entire history - fetch-depth: 0 - # We need to explicitly preserve the credentials for the GitHub token - # on this branch so we can push to it. - persist-credentials: true - - - name: Push the built HTML to gh-pages - run: | - # Detect if this is a release or from the main branch - if [[ "${{ github.event_name }}" == "release" ]]; then - # Get the tag name without the "refs/tags/" part - version="${GITHUB_REF#refs/*/}" - else - version=dev - fi - echo "Deploying version: $version" - # Make the new commit message. Needs to happen before cd into deploy - # to get the right commit hash. - message="Deploy $version from $(git rev-parse --short HEAD)" - cd deploy || exit 1 - # Need to have this file so that Github doesn't try to run Jekyll - touch .nojekyll - # Delete all the files and replace with our new set - echo -e "\nRemoving old files from previous builds of ${version}:" - rm -rvf "${version}" - echo -e "\nCopying HTML files to ${version}:" - cp -Rvf ../doc/_build/html/ "${version}/" - # If this is a new release, update the link from /latest to it - if [[ "${version}" != "dev" ]]; then - echo -e "\nSetup link from ${version} to 'latest'." - rm -f latest - ln -sf "${version}" latest - fi - # Stage the commit - git add -A . - echo -e "\nChanges to be applied:" - git status - # Configure git to be the GitHub Actions account - git config user.email "github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - # If this is a dev build and the last commit was from a dev build - # (detect if "dev" was in the previous commit message), reuse the - # same commit - if [[ "${version}" == "dev" && $(git log -1 --format='%s') == *"dev"* ]]; then - echo -e "\nAmending last commit:" - git commit --amend --reset-author -m "$message" - else - echo -e "\nMaking a new commit:" - git commit -m "$message" - fi - # Make the push quiet just in case there is anything that could leak - # sensitive information. - echo -e "\nPushing changes to gh-pages." - { git push -fq origin gh-pages >/dev/null; } 2>&1 - echo -e "\nFinished uploading generated files." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..87641f08b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,173 @@ +# Build and publish packages to PyPI and documentation to GitHub Pages +# +name: publish + +# Only pushed to the main branch and releases. +on: + push: + branches: + - main + - cache-poisoning + release: + types: + - published + +permissions: {} + +# Use bash by default in all jobs +defaults: + run: + # The -l {0} is necessary for conda environments to be activated + # But this breaks on MacOS if using actions/setup-python: + # https://github.com/actions/setup-python/issues/132 + # -e makes sure builds fail if any command fails + shell: bash -e -l {0} + +jobs: + + deploy: + runs-on: ubuntu-latest + permissions: + # Grant write permissions so that we can push to gh-pages + contents: write + # This permission allows trusted publishing to PyPI (without an API token) + id-token: write + environment: pypi + env: + REQUIREMENTS: env/requirements-docs.txt env/requirements-build.txt + PYTHON: "3.14" + + steps: + # Checks-out your repository under $GITHUB_WORKSPACE + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + # Need to fetch more than the last commit so that setuptools-scm can + # create the correct version string. If the number of commits since + # the last release is greater than this, the version still be wrong. + # Increase if necessary. + fetch-depth: 100 + # The GitHub token is preserved by default but this job doesn't need + # to be able to push to GitHub. + persist-credentials: false + + # Need the tags so that setuptools-scm can form a valid version number + - name: Fetch git tags + run: git fetch origin 'refs/tags/*:refs/tags/*' + + - name: Setup Miniforge + uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 + with: + python-version: ${{ env.PYTHON }} + miniforge-version: latest + channels: conda-forge + conda-remove-defaults: "true" + activate-environment: harmonica-docs + environment-file: .github/environment.yml + + - name: List installed packages + run: conda list + + - name: Build source and wheel distributions + run: | + make build + echo "" + echo "Generated files:" + ls -lh dist/ + + - name: Check the archives + run: twine check dist/* + + - name: Install the package + run: python -m pip install --no-deps dist/*.whl + + - name: Build the documentation + run: make -C doc clean all + + - name: Checkout the gh-pages branch in a separate folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: gh-pages + # Checkout to this folder instead of the current one + path: deploy + # Download the entire history + fetch-depth: 0 + # We need to explicitly preserve the credentials for the GitHub token + # on this branch so we can push to it. + persist-credentials: true + + - name: Push the built HTML to gh-pages + if: success() + run: | + # Detect if this is a release or from the main branch + if [[ "${{ github.event_name }}" == "release" ]]; then + # Get the tag name without the "refs/tags/" part + version="${GITHUB_REF#refs/*/}" + else + version=dev + fi + echo "Deploying version: $version" + + # Make the new commit message. Needs to happen before cd into deploy + # to get the right commit hash. + message="Deploy $version from $(git rev-parse --short HEAD)" + + cd deploy || exit 1 + + # Need to have this file so that Github doesn't try to run Jekyll + touch .nojekyll + + # Delete all the files and replace with our new set + echo -e "\nRemoving old files from previous builds of ${version}:" + rm -rvf "${version}" + echo -e "\nCopying HTML files to ${version}:" + cp -Rvf ../doc/_build/html/ "${version}/" + + # If this is a new release, update the link from /latest to it + if [[ "${version}" != "dev" ]]; then + echo -e "\nSetup link from ${version} to 'latest'." + rm -f latest + ln -sf "${version}" latest + fi + + # Stage the commit + git add -A . + echo -e "\nChanges to be applied:" + git status + + # Configure git to be the GitHub Actions account + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + # If this is a dev build and the last commit was from a dev build + # (detect if "dev" was in the previous commit message), reuse the + # same commit + if [[ "${version}" == "dev" && $(git log -1 --format='%s') == *"dev"* ]]; then + echo -e "\nAmending last commit:" + git commit --amend --reset-author -m "$message" + else + echo -e "\nMaking a new commit:" + git commit -m "$message" + fi + + # Make the push quiet just in case there is anything that could leak + # sensitive information. + echo -e "\nPushing changes to gh-pages." + { git push -fq origin gh-pages > /dev/null; } 2>&1 + + echo -e "\nFinished uploading generated files." + + - name: Publish to Test PyPI + # Only publish to TestPyPI when a PR is merged (pushed to main) + if: success() && github.event_name == 'push' + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + repository_url: https://test.pypi.org/legacy/ + # Allow existing releases on test PyPI without errors. + # NOT TO BE USED in PyPI! + skip_existing: true + + - name: Publish to PyPI + # Only publish to PyPI when a release triggers the build + if: success() && github.event_name == 'release' + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index 42b4c9d6a..000000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,128 +0,0 @@ -# Publish archives to PyPI and TestPyPI using GitHub Actions. -# -# NOTE: Pin actions to a specific commit to avoid having the authentication -# token stolen if the Action is compromised. See the comments and links here: -# https://github.com/pypa/gh-action-pypi-publish/issues/27 -# -name: pypi - -# Runs on these events but only publish on pushes to main (to test pypi) and -# releases -on: - pull_request: - push: - branches: - - main - release: - types: - - published - -permissions: {} - -# Use bash by default in all jobs -defaults: - run: - shell: bash - -jobs: - ############################################################################# - # Build and check source and wheel distributions - build: - runs-on: ubuntu-latest - - steps: - # Checks-out your repository under $GITHUB_WORKSPACE - - name: Checkout - uses: actions/checkout@v6 - with: - # Need to fetch more than the last commit so that setuptools_scm can - # create the correct version string. If the number of commits since - # the last release is greater than this, the version will still be - # wrong. Increase if necessary. - fetch-depth: 200 - # The GitHub token is preserved by default but this job doesn't need - # to be able to push to GitHub. - persist-credentials: false - - # Need the tags so that setuptools-scm can form a valid version number - - name: Fetch git tags - run: git fetch origin 'refs/tags/*:refs/tags/*' - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install requirements - run: python -m pip install -r env/requirements-build.txt - - - name: List installed packages - run: python -m pip freeze - - - name: Don't use local version numbers for TestPyPI uploads - if: github.event_name != 'release' - run: | - # Change setuptools-scm local_scheme to "no-local-version" so the - # local part of the version isn't included, making the version string - # compatible with Test PyPI. - sed --in-place "s/node-and-date/no-local-version/g" pyproject.toml - - - name: Build source and wheel distributions - run: | - make build - echo "" - echo "Generated files:" - ls -lh dist/ - - - name: Check the archives - run: twine check dist/* - - # Store the archives as a build artifact so we can deploy them later - - name: Upload archives as artifacts - # Only if not a pull request - if: success() && github.event_name != 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: pypi-${{ github.sha }} - path: dist - - ############################################################################# - # Publish built wheels and source archives to PyPI and test PyPI - publish: - runs-on: ubuntu-latest - needs: build - # Only publish from the origin repository, not forks - if: github.repository_owner == 'fatiando' && github.event_name != 'pull_request' - environment: pypi - permissions: - # This permission allows trusted publishing to PyPI (without an API token) - id-token: write - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # The GitHub token is preserved by default but this job doesn't need - # to be able to push to GitHub. - persist-credentials: false - - - name: Download built source and wheel packages - uses: actions/download-artifact@v8 - with: - name: pypi-${{ github.sha }} - path: dist - - - name: Publish to Test PyPI - # Only publish to TestPyPI when a PR is merged (pushed to main) - if: success() && github.event_name == 'push' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b - with: - repository_url: https://test.pypi.org/legacy/ - # Allow existing releases on test PyPI without errors. - # NOT TO BE USED in PyPI! - skip_existing: true - - - name: Publish to PyPI - # Only publish to PyPI when a release triggers the build - if: success() && github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b23be103..8a7fca1b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,24 +6,22 @@ # name: test -# Only build PRs, the main branch, and releases. Pushes to branches will only -# be built when a PR is opened. This avoids duplicated buids in PRs comming -# from branches in the origin repository (1 for PR and 1 for push). on: pull_request: push: branches: - main - release: - types: - - published permissions: {} # Use bash by default in all jobs defaults: run: - shell: bash + # The -l {0} is necessary for conda environments to be activated + # But this breaks on MacOS if using actions/setup-python: + # https://github.com/actions/setup-python/issues/132 + # -e makes sure builds fail if any command fails + shell: bash -e -o pipefail -l {0} jobs: ############################################################################# @@ -124,7 +122,13 @@ jobs: - name: List installed packages run: python -m pip freeze - # Needs to be editable for coverage reporting to work + - name: Build source and wheel distributions + run: make build + + - name: Check the archives + run: twine check dist/* + + # Do this instead of using the wheel so we can get proper coverage reports - name: Install the package in editable mode run: python -m pip install --no-deps --editable . From 34e2863cc974e5f28c2adaab4a816cb78fac153d Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Tue, 19 May 2026 14:43:51 -0300 Subject: [PATCH 2/6] Don't run tests on old Mac OS anymore --- .github/workflows/publish.yml | 4 ++-- .github/workflows/test.yml | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 87641f08b..3a6da26cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -162,10 +162,10 @@ jobs: if: success() && github.event_name == 'push' uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b with: - repository_url: https://test.pypi.org/legacy/ + repository-url: https://test.pypi.org/legacy/ # Allow existing releases on test PyPI without errors. # NOT TO BE USED in PyPI! - skip_existing: true + skip-existing: true - name: Publish to PyPI # Only publish to PyPI when a release triggers the build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a7fca1b9..fb6da34a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,10 +49,6 @@ jobs: python: "3.13" - dependencies: optional python: "3.13" - # Include tests on macos (x86) with oldest dependencies - - os: macos-15-intel - dependencies: oldest - python: "3.10" env: REQUIREMENTS: env/requirements-build.txt env/requirements-tests.txt From bfe547f123c8ff121f412991dd935fa5fce192b0 Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Tue, 19 May 2026 14:44:50 -0300 Subject: [PATCH 3/6] Try building docs for publishing in 3.12 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a6da26cb..c7580ead5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: environment: pypi env: REQUIREMENTS: env/requirements-docs.txt env/requirements-build.txt - PYTHON: "3.14" + PYTHON: "3.12" steps: # Checks-out your repository under $GITHUB_WORKSPACE From 8a1776ef4c606d595ef3f9e28149cf486af9bd0a Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Wed, 20 May 2026 09:09:50 -0300 Subject: [PATCH 4/6] Add missing docs build steps for pyvista --- .github/workflows/publish.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c7580ead5..c35783a9b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,5 +1,9 @@ # Build and publish packages to PyPI and documentation to GitHub Pages # +# NOTE: Pin actions to a specific commit to avoid having the authentication +# token stolen if the Action is compromised. See the comments and links here: +# https://github.com/pypa/gh-action-pypi-publish/issues/27 +# name: publish # Only pushed to the main branch and releases. @@ -21,7 +25,7 @@ defaults: # But this breaks on MacOS if using actions/setup-python: # https://github.com/actions/setup-python/issues/132 # -e makes sure builds fail if any command fails - shell: bash -e -l {0} + shell: bash -e -o pipefail -l {0} jobs: @@ -82,7 +86,17 @@ jobs: run: python -m pip install --no-deps dist/*.whl - name: Build the documentation - run: make -C doc clean all + run: | + # Install xvfb and run some commands to allow pyvista to run on + # a headless system. + sudo apt-get install xvfb + export DISPLAY=:99.0 + export PYVISTA_OFF_SCREEN=true + export PYVISTA_USE_IPYVTK=true + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + sleep 3 + # Build the docs + make -C doc clean all - name: Checkout the gh-pages branch in a separate folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd From d2041a0c660748ed63be115322592b56a8f53108 Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Wed, 20 May 2026 09:30:20 -0300 Subject: [PATCH 5/6] No local version for PyPI --- .github/workflows/publish.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c35783a9b..da421d85b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -72,6 +72,13 @@ jobs: - name: List installed packages run: conda list + - name: Don't use local version numbers for PyPI uploads + run: | + # Change setuptools-scm local_scheme to "no-local-version" so the + # local part of the version isn't included, making the version string + # compatible with Test PyPI. + sed --in-place "s/node-and-date/no-local-version/g" pyproject.toml + - name: Build source and wheel distributions run: | make build From 6aec1890d6fd1c3dda5510025dc92ec3373ec92d Mon Sep 17 00:00:00 2001 From: Leonardo Uieda Date: Wed, 20 May 2026 09:59:44 -0300 Subject: [PATCH 6/6] Stop deploying from this branch --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index da421d85b..20219d6c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,6 @@ on: push: branches: - main - - cache-poisoning release: types: - published @@ -39,6 +38,7 @@ jobs: environment: pypi env: REQUIREMENTS: env/requirements-docs.txt env/requirements-build.txt + ENSAIO_DATA_FROM_GITHUB: true PYTHON: "3.12" steps: