From 9558d447266d9b16333859ed01f8779a3fff099c Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 11:41:56 -0400 Subject: [PATCH 1/4] ci: pin every action to a commit SHA and let Dependabot update them (#1968) - Every `uses:` of an action outside the repository now names the commit its tag resolved to, with the release as a `# vX.Y.Z` comment; each action keeps its current major line. - `.github/dependabot.yml` adds the `github-actions` ecosystem from `/`, weekly like `pip`, with dependabot's default labels. - `tests/_workflows.py` gains `pinned()` and `versions()`; the tests that named an action by tag now check the pin and its version comment. - `tests/project/test_actions_pinned_1968.py` fails on an unpinned `uses:` or a missing `github-actions` ecosystem. - Docs: an Action Pins section in workflows.rst, the pinned versions in releasing.rst, and dependabot's labels in conventions/process.rst. --- .github/dependabot.yml | 5 + .github/workflows/ci-timing.yml | 6 +- .github/workflows/codeql-analysis.yml | 8 +- .github/workflows/create-release.yml | 34 ++--- .github/workflows/cron-conda.yml | 22 +-- .github/workflows/cron-python-versions.yml | 10 +- .github/workflows/cron-vendor.yml | 8 +- .github/workflows/deploy-pages.yml | 12 +- .github/workflows/experimental.yml | 18 +-- .github/workflows/lint.yml | 4 +- .github/workflows/python-compatibility.yml | 6 +- .github/workflows/unit-tests.yml | 56 ++++---- .../contributing/conventions/process.rst | 18 +-- docs/source/contributing/releasing.rst | 14 +- docs/source/contributing/workflows.rst | 22 +++ tests/_workflows.py | 23 ++- tests/project/test_actions_pinned_1968.py | 131 ++++++++++++++++++ tests/project/test_ci_timing.py | 2 +- ...est_conda_dist_keeps_release_notes_1657.py | 10 +- tests/project/test_conventions_doc_claims.py | 30 ++-- .../test_python_versions_update_1660.py | 4 +- .../test_release_tags_the_run_sha_1634.py | 9 +- 22 files changed, 322 insertions(+), 130 deletions(-) create mode 100644 tests/project/test_actions_pinned_1968.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 91abb11fdf..5ed682a5c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,8 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + + - package-ecosystem: "github-actions" # Every `uses:` under .github/workflows + directory: "/" # Dependabot reads .github/workflows from the repository root + schedule: + interval: "weekly" diff --git a/.github/workflows/ci-timing.yml b/.github/workflows/ci-timing.yml index eee734ec0d..d7c56b149b 100644 --- a/.github/workflows/ci-timing.yml +++ b/.github/workflows/ci-timing.yml @@ -53,7 +53,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # #1660: `latest` from .github/python-versions.toml, read with the # runner's own python3. @@ -61,7 +61,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -117,7 +117,7 @@ jobs: # 90 days, so the trend is a series of weekly reports rather than one. - name: Upload the report if: ${{ always() }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ci-timing-report path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2714cda8ba..827a4e05f3 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,11 +36,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 # Override language selection by uncommenting this and choosing your languages # with: # languages: go, javascript, csharp, python, cpp, java @@ -48,7 +48,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -62,4 +62,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e709bc0a74..8582d51bed 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -296,7 +296,7 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -309,7 +309,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -328,7 +328,7 @@ jobs: echo "PCAPKIT_PRERELEASE=$(python -c 'from packaging.version import Version; import pcapkit; print(str(Version(pcapkit.__version__).is_prerelease).lower())')" >> $GITHUB_OUTPUT echo "PCAPKIT_CONDA_LABEL=$(python -c 'from packaging.version import Version; import pcapkit; print("dev" if Version(pcapkit.__version__).is_prerelease else "main")')" >> $GITHUB_OUTPUT - - uses: mukunku/tag-exists-action@v1.7.0 + - uses: mukunku/tag-exists-action@5c39604fe8aef7e65acb6fbcf96ec580f7680313 # v1.7.0 id: check_tag with: tag: "v${{ steps.get_version.outputs.PCAPKIT_VERSION }}" @@ -340,7 +340,7 @@ jobs: # release leaves the ``v*`` tag behind while those are still # incomplete, and gating them on it is what made every one of them skip # silently. Each gets its own evidence step below instead. - - uses: mukunku/tag-exists-action@v1.7.0 + - uses: mukunku/tag-exists-action@5c39604fe8aef7e65acb6fbcf96ec580f7680313 # v1.7.0 id: check_conda_tag with: tag: "conda-${{ steps.get_version.outputs.PCAPKIT_VERSION }}+0" @@ -624,7 +624,7 @@ jobs: (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false') }} steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -692,7 +692,7 @@ jobs: - name: Mint the release App's token id: app_token if: ${{ vars.RELEASE_APP_CLIENT_ID != '' }} - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -701,7 +701,7 @@ jobs: - name: Create Release id: create_release - uses: softprops/action-gh-release@v3.0.2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: # allowUpdates: true # artifacts: | @@ -814,7 +814,7 @@ jobs: (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS == 'false') }} steps: - name: Load maintenance deploy key - uses: webfactory/ssh-agent@v0.9.1 + uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} @@ -823,7 +823,7 @@ jobs: # which fast-forwarded it onto ``main``'s tip first -- ``b6b95bc8e`` to # ``2c0008c3c`` in job 115087308551 (#1634). - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.PYPCAPKIT }} ref: ${{ github.sha }} @@ -1009,7 +1009,7 @@ jobs: # whatever ``main`` had become by approval time -- and then the check # below that the tag these files are filed under names it too. - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -1032,7 +1032,7 @@ jobs: echo "${tag} -> ${run_commit}" done - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: setup continue-on-error: ${{ matrix.ktlo == true }} with: @@ -1071,7 +1071,7 @@ jobs: id: publish if: ${{ steps.build.outcome == 'success' && steps.no_sdist.outcome != 'failure' }} continue-on-error: ${{ matrix.ktlo == true }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: # password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true @@ -1084,7 +1084,7 @@ jobs: - name: Publish package to TestPyPI if: ${{ steps.publish.outcome == 'success' }} continue-on-error: true - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: # password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ @@ -1238,7 +1238,7 @@ jobs: steps: # #1634: this run's commit, not ``ref: conda-+0``, then the check # that both tags these packages are filed under name it. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 @@ -1334,7 +1334,7 @@ jobs: echo "::notice title=conda leg already published::pypcapkit ${PCAPKIT_VERSION} for ${target_platform} / ${py_tag} is already on anaconda.org/jarryshaw (http ${status}); skipping this leg's upload." fi - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -1378,7 +1378,7 @@ jobs: # measured to build and import pypcapkit-*-py315_0. Drop the exception # once conda publishes a py315 build. - name: Conda environment creation and activation - uses: conda-incubator/setup-miniconda@v4.0.1 + uses: conda-incubator/setup-miniconda@8ee1f361103df19b6f8c8655fd3967a8ecb162d5 # v4.0.1 env: PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} PCAPKIT_BUILD: "0" @@ -1473,7 +1473,7 @@ jobs: - name: Upload conda packages to Anaconda if: ${{ steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }} - uses: anaconda/actions/upload-package@v0.3.1 + uses: anaconda/actions/upload-package@a6f488910b4a1e4f3fe01736241ca1b92b4b355a # v0.3.1 with: token: ${{ secrets.ANACONDA_TOKEN }} channel: jarryshaw diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 767dc960ab..dc7703f7e4 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -75,7 +75,7 @@ jobs: # The default ref, as the job below uses, so both see the same `github.sha`. # Twenty commits leaves room for the bot commits the rule accepts; a verdict # further back is not found, and so not accepted. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 persist-credentials: false @@ -118,11 +118,11 @@ jobs: contents: write steps: - name: Load maintenance deploy key - uses: webfactory/ssh-agent@v0.9.1 + uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Deliberately the default `github.sha` (main's tip), not # `github.event.workflow_run.head_sha`. Pinning to the latter once @@ -152,7 +152,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -372,7 +372,7 @@ jobs: contents: write steps: - name: Load maintenance deploy key - uses: webfactory/ssh-agent@v0.9.1 + uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} @@ -383,7 +383,7 @@ jobs: # history for all branches and tags" (actions/checkout), so Create Tag sees # a tag that already exists. - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.conda-update.outputs.CONDA_COMMIT }} token: ${{ secrets.PYPCAPKIT }} @@ -466,12 +466,12 @@ jobs: # there is no KTLO 3.10 leg. python-version: ${{ fromJSON(needs.conda-update.outputs.PYTHON_CONDA) }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: conda-${{ needs.conda-update.outputs.PCAPKIT_VERSION }}+${{ needs.conda-update.outputs.PCAPKIT_BUILD }} fetch-depth: 0 - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -508,7 +508,7 @@ jobs: # See create-release.yml's step of the same name for the 3.15 exception. - name: Conda environment creation and activation - uses: conda-incubator/setup-miniconda@v4.0.1 + uses: conda-incubator/setup-miniconda@8ee1f361103df19b6f8c8655fd3967a8ecb162d5 # v4.0.1 env: PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} PCAPKIT_BUILD: ${{ needs.conda-update.outputs.PCAPKIT_BUILD }} @@ -608,7 +608,7 @@ jobs: "${pythonLocation}/bin/python" util/conda_vendor.py --smoke "$PCAPKIT_CONDA_PACKAGE" - name: Upload conda packages to Anaconda - uses: anaconda/actions/upload-package@v0.3.1 + uses: anaconda/actions/upload-package@a6f488910b4a1e4f3fe01736241ca1b92b4b355a # v0.3.1 with: token: ${{ secrets.ANACONDA_TOKEN }} channel: jarryshaw @@ -670,7 +670,7 @@ jobs: # wrote. - name: Upload package to GitHub Release if: ${{ matrix.os == 'ubuntu-latest' && steps.release.outputs.published == 'true' }} - uses: ncipollo/release-action@v1.21.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: allowUpdates: true artifacts: ${{ steps.conda_build.outputs.packages_csv }} diff --git a/.github/workflows/cron-python-versions.yml b/.github/workflows/cron-python-versions.yml index ec2880046e..a106344daa 100644 --- a/.github/workflows/cron-python-versions.yml +++ b/.github/workflows/cron-python-versions.yml @@ -43,7 +43,7 @@ jobs: publish: ${{ steps.update.outputs.publish }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -52,7 +52,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -106,7 +106,7 @@ jobs: # files/.github/..., which upload-artifact leaves out unless told otherwise. - name: Upload the proposal if: ${{ steps.update.outputs.publish == 'true' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: python-versions-proposal path: ${{ runner.temp }}/proposal @@ -150,7 +150,7 @@ jobs: steps: - name: Mint the release App's token id: app - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -158,7 +158,7 @@ jobs: permission-pull-requests: write # The commit `detect` read, with full history for the branch's merge base. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 diff --git a/.github/workflows/cron-vendor.yml b/.github/workflows/cron-vendor.yml index c3075b692b..0029faf30e 100644 --- a/.github/workflows/cron-vendor.yml +++ b/.github/workflows/cron-vendor.yml @@ -81,7 +81,7 @@ jobs: # The default ref, as the job below uses, so both see the same `github.sha`. # Twenty commits leaves room for the bot commits the rule accepts; a verdict # further back is not found, and so not accepted. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 persist-credentials: false @@ -122,11 +122,11 @@ jobs: contents: write steps: - name: Load maintenance deploy key - uses: webfactory/ssh-agent@v0.9.1 + uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Deliberately the default `github.sha` (main's tip), not # `github.event.workflow_run.head_sha`. Pinning to the latter once @@ -152,7 +152,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 59a6286fe2..91bf7873c8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -80,7 +80,7 @@ jobs: # The default ref, as the job below uses, so both see the same `github.sha`. # Twenty commits leaves room for the bot commits the rule accepts; a verdict # further back is not found, and so not accepted. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 persist-credentials: false @@ -118,7 +118,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout đŸ›Žī¸ - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Deliberately the default `github.sha` (main's tip), not # `github.event.workflow_run.head_sha`. Pinning to the latter once @@ -141,7 +141,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -177,7 +177,7 @@ jobs: # told otherwise. - name: Upload the built site if: ${{ github.event_name != 'pull_request' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pages-html path: docs/build/html @@ -210,7 +210,7 @@ jobs: steps: # The deploy action pushes from a checkout of this repository. - name: Checkout đŸ›Žī¸ - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -250,7 +250,7 @@ jobs: - name: Deploy 🚀 if: steps.fresh.outputs.publish == 'true' - uses: JamesIves/github-pages-deploy-action@v4 + uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index 9cdd8b8190..1b2067a013 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -60,7 +60,7 @@ jobs: current: ${{ steps.check.outputs.current }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 continue-on-error: true with: sparse-checkout: | @@ -78,7 +78,7 @@ jobs: continue-on-error: true run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: true with: @@ -109,7 +109,7 @@ jobs: latest: ${{ steps.read.outputs.latest }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github/python-versions.toml @@ -135,7 +135,7 @@ jobs: code: ${{ steps.classify.outputs.code }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ github.event_name == 'pull_request' }} with: fetch-depth: 2 @@ -238,7 +238,7 @@ jobs: matrix: ${{ fromJSON(needs.python-versions.outputs.experimental) }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 id: checkout continue-on-error: true @@ -257,7 +257,7 @@ jobs: latest=$(python3 -c "from tests._tiers import latest_tier_paths; print(' '.join(latest_tier_paths()))") echo "paths=$latest" >> "$GITHUB_OUTPUT" - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: true timeout-minutes: 5 @@ -302,7 +302,7 @@ jobs: # leg's. No `cache:` and `--no-cache-dir`, so the leg's pip cache is # untouched. Each step has a cap of 1 minute (see the job's comment), # and a failure leaves the tests to run unwatched. - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: watcher-python if: ${{ steps.install.outcome == 'success' }} continue-on-error: true @@ -551,7 +551,7 @@ jobs: - name: Upload the resource monitor's log if: ${{ always() }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: resource-monitor-${{ matrix.python-version }} path: ${{ runner.temp }}/resource-monitor.log @@ -564,7 +564,7 @@ jobs: - name: Upload test timings if: ${{ !cancelled() && steps.install.outcome == 'success' }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-experimental-${{ matrix.python-version }} path: ${{ runner.temp }}/junit.xml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f81992b8b4..9f26ca1aeb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -109,7 +109,7 @@ jobs: timeout-minutes: 30 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # One interpreter, not a matrix. Unit Tests and Python Compatibility sweep # the supported tier, plus the non-blocking KTLO legs (#1633), because @@ -130,7 +130,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index 988abca05c..2b3e1d3595 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -32,7 +32,7 @@ jobs: outputs: compatibility: ${{ steps.read.outputs.compatibility }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github/python-versions.toml @@ -73,11 +73,11 @@ jobs: # tier change edits the ruleset too. matrix: ${{ fromJSON(needs.python-versions.outputs.compatibility) }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 id: checkout continue-on-error: ${{ matrix.ktlo == true }} - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: ${{ matrix.ktlo == true }} with: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index fd5b2173e9..3b34cdad65 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -73,7 +73,7 @@ jobs: engine-ktlo: ${{ steps.read.outputs.engine-ktlo }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: | .github/python-versions.toml @@ -122,11 +122,11 @@ jobs: # `&test-steps`: `test-ktlo` runs these same steps, by YAML alias. steps: &test-steps - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 id: checkout continue-on-error: ${{ matrix.ktlo == true }} - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: ${{ matrix.ktlo == true }} with: @@ -373,7 +373,7 @@ jobs: # that passed is the baseline the failing one has to be read against. - name: Upload coverage process log if: ${{ !cancelled() && matrix.coverage }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-processes-log path: coverage-processes.log @@ -382,7 +382,7 @@ jobs: - name: Upload coverage HTML report if: ${{ !cancelled() && matrix.coverage }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-html path: coverage-html/ @@ -390,7 +390,7 @@ jobs: - name: Upload coverage summary if: ${{ !cancelled() && matrix.coverage }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-summary path: coverage-comment/coverage-summary.json @@ -403,7 +403,7 @@ jobs: - name: Upload test timings if: ${{ !cancelled() && steps.install.outcome == 'success' }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-unit-${{ matrix.python-version }} path: ${{ runner.temp }}/junit.xml @@ -496,11 +496,11 @@ jobs: # `&integration-steps`: `integration-ktlo` runs these same steps. steps: &integration-steps - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 id: checkout continue-on-error: ${{ matrix.ktlo == true }} - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: ${{ matrix.ktlo == true }} with: @@ -651,7 +651,7 @@ jobs: - name: Upload test timings if: ${{ !cancelled() && steps.install.outcome == 'success' }} continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ matrix.python-version }} path: ${{ runner.temp }}/junit.xml @@ -780,7 +780,7 @@ jobs: - PCAP_CT steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Each interpreter is then reachable as `python3.X`. The KTLO ones are # installed by a step of their own so that losing one cannot fail the @@ -788,13 +788,13 @@ jobs: # `python`, and is skipped when the tier has no cell here. The supported # ones share the second step. Each output is one version per line, the # list form setup-python reads. - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 if: ${{ needs.python-versions.outputs.engine-ktlo != '' }} continue-on-error: true with: python-version: ${{ needs.python-versions.outputs.engine-ktlo }} - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ needs.python-versions.outputs.engine-supported }} @@ -892,7 +892,7 @@ jobs: - name: Restore this engine's cached .deb files id: apt-cache if: steps.apt-plan.outputs.key != '' - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/apt-debs/*.deb key: ${{ steps.apt-plan.outputs.key }} @@ -960,7 +960,7 @@ jobs: if: >- env.APT_CACHE_SAVE == 'true' && steps.apt-plan.outputs.key != '' && steps.apt-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/apt-debs/*.deb key: ${{ steps.apt-plan.outputs.key }} @@ -1204,11 +1204,11 @@ jobs: # `&pypcap-parity-steps`: `pypcap-parity-ktlo` runs these same steps. steps: &pypcap-parity-steps - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 id: checkout continue-on-error: ${{ matrix.ktlo == true }} - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 id: python continue-on-error: ${{ matrix.ktlo == true }} with: @@ -1269,7 +1269,7 @@ jobs: id: apt-cache if: steps.apt-plan.outputs.key != '' continue-on-error: ${{ matrix.ktlo == true }} - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/apt-debs/*.deb key: ${{ steps.apt-plan.outputs.key }} @@ -1332,7 +1332,7 @@ jobs: if: >- env.APT_CACHE_SAVE == 'true' && steps.apt-plan.outputs.key != '' && steps.apt-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/apt-debs/*.deb key: ${{ steps.apt-plan.outputs.key }} @@ -1618,7 +1618,7 @@ jobs: label: protocols (rest) steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # `latest` from .github/python-versions.toml, read here rather than # through `python-versions`: see that job's comment. @@ -1626,7 +1626,7 @@ jobs: id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} cache: pip @@ -1678,14 +1678,14 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # See `unittest-ordering` above. - name: Read the Python versions id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} @@ -1727,7 +1727,7 @@ jobs: code: ${{ steps.classify.outputs.code }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ github.event_name == 'pull_request' && github.workflow == 'Unit Tests' }} with: fetch-depth: 2 @@ -1802,14 +1802,14 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # See `unittest-ordering` above. - name: Read the Python versions id: python-versions run: python3 util/python_versions.py --github-output - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ steps.python-versions.outputs.latest }} cache: pip @@ -1876,9 +1876,9 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ needs.python-versions.outputs.latest }} cache: pip diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index dbdb748b93..91c542b29f 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -158,11 +158,12 @@ it is a record that the owner asserted it. Some paths are automated, and each is worth knowing about because a label they set has had no human judgement behind it: -* **dependabot** puts ``dependencies`` and ``python`` on its own pull requests, and - only those: :file:`.github/dependabot.yml` configures only the ``pip`` - ecosystem, so it never opens a workflow bump here. ``github_actions`` exists as a - label but is **hand-applied like the rest** -- naming it as dependabot's would tell - a reader the opposite of this section's point. +* **dependabot** puts ``dependencies``, ``python`` and ``github_actions`` on its own + pull requests, and only those: :file:`.github/dependabot.yml` configures the + ``pip`` and ``github-actions`` ecosystems and no ``labels:`` key, so each bump + takes dependabot's defaults -- ``dependencies`` always, with ``python`` on a + ``pip`` bump and ``github_actions`` on a workflow bump. On anyone else's pull + request ``github_actions`` is **hand-applied like the rest**. * **The issue templates** apply an issue-kind label from their front matter, before anyone reads the issue -- :file:`.github/ISSUE_TEMPLATE/bug_report.md` carries ``labels: bug`` and :file:`.github/ISSUE_TEMPLATE/feature_request.md` carries @@ -273,10 +274,9 @@ top of a review already in progress, and the second verdict then arrives against the first was never given. Separating the two makes *which pull requests still need a reviewer* answerable from the board rather than from memory of what was dispatched. -**Scope.** ``dependencies`` and ``python`` are dependabot's, per above. -``github_actions`` is the same kind of label -- it scopes a change to the workflows -- -but nothing applies it automatically, because dependabot is not configured for that -ecosystem here. +**Scope.** ``dependencies``, ``python`` and ``github_actions`` are dependabot's on +its own pull requests, per above. ``github_actions`` scopes a change to the workflows, +and on any other pull request it is hand-applied. The ``breaking`` Label ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index ed127f3e9a..b657ea9179 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -229,8 +229,9 @@ spends a GitHub App's token instead (`The Release App`_). After ``github``, nothing in this workflow calls Update a release, so by the docs nothing re-checks the target (unmeasured). ``cron-conda.yml``'s ``conda-dist`` does: it attaches packages with -``ncipollo/release-action@v1.21.0`` and ``allowUpdates: true``, which updates -the release before uploading (``src/Action.ts:53-66,107-120``). It therefore +``ncipollo/release-action`` v1.21.0, pinned to its commit, and +``allowUpdates: true``, which updates the release before uploading +(``src/Action.ts:53-66,107-120``). It therefore names the default branch -- ``github.ref_name`` whichever trigger raised it -- as ``commit``, which that update sends as ``target_commitish`` (``src/Releases.ts:150-174``), so the rule checks ``main`` itself; the tag @@ -263,10 +264,11 @@ the release with a GitHub App installation token, which Create a release accepts for a target that changes a workflow file when it has the "Workflows" repository permission (write). "Mint the release App's token" (``create-release.yml:692-700``) runs -``actions/create-github-app-token@v3`` with ``permission-contents: write`` and -``permission-workflows: write`` and no ``owner`` or ``repositories``, so the -token is "scoped to only the current repository" and revoked in the action's -``post`` step (v3.3.0's README). Only Create Release spends it (``:736``). +``actions/create-github-app-token`` v3.3.0, pinned to its commit, with +``permission-contents: write`` and ``permission-workflows: write`` and no +``owner`` or ``repositories``, so the token is "scoped to only the current +repository" and revoked in the action's ``post`` step (v3.3.0's README). Only +Create Release spends it (``:736``). While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the workflow keeps working, refusals included, until the App exists. A PAT would diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 5323cd3d95..2e3bdc1c48 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1053,6 +1053,28 @@ above). ``unit-tests.yml`` runs pytest, unittest or :file:`util/run_unittest_leg.py` without the script, and runs the script against a small limit. +Action Pins (`#1968 `__) +---------------------------------------------------------------------------- + +A tag or a branch is a reference its owner can move, so every ``uses:`` of an +action outside this repository names a full commit, with the release that commit +is as a trailing comment:: + + - uses: actions/checkout@<40-hex commit> # v7.0.1 + +A step's ``uses`` is therefore the commit alone, as the YAML reads it; the comment +is what Dependabot rewrites with the commit on a bump. A local ``./`` workflow call +stays as it is. :file:`.github/dependabot.yml` watches the ``github-actions`` +ecosystem from the repository root beside ``pip``, on the same weekly schedule, so a +new release reaches the workflows as a Dependabot pull request rather than by a tag +moving under a run. + +:file:`tests/project/test_actions_pinned_1968.py` fails on a ``uses:`` under +:file:`.github` that names a tag, a branch or a commit without its version comment, +and when :file:`.github/dependabot.yml` drops the ``github-actions`` ecosystem. +``pinned()`` and ``versions()`` in :file:`tests/_workflows.py` read a pin, for a test +that names a particular action. + Python Versions --------------- diff --git a/tests/_workflows.py b/tests/_workflows.py index 789524176e..e3106f59b4 100644 --- a/tests/_workflows.py +++ b/tests/_workflows.py @@ -15,6 +15,10 @@ the trigger key. * **Without PyYAML** (the ``test`` extra) every helper raises :exc:`unittest.SkipTest`, so a test that needs one skips. +* **Every action is pinned to a commit** (#1968), written + ``uses: owner/repo@ # vX.Y.Z``. A parsed ``uses`` holds only + ``owner/repo@``, since YAML drops the comment, so :func:`pinned` checks + the reference and :func:`versions` reads the comment from the raw text. """ @@ -22,6 +26,7 @@ import copy import functools +import re import unittest from typing import TYPE_CHECKING @@ -43,7 +48,7 @@ PathLike = Union[str, 'os.PathLike[str]'] -__all__ = ['WORKFLOWS', 'NO_YAML', 'load', 'loads', 'job', 'steps'] +__all__ = ['WORKFLOWS', 'NO_YAML', 'PINNED', 'load', 'loads', 'job', 'steps', 'pinned', 'versions'] #: Directory holding the workflow files. A relative name given to :func:`load` is #: read from here; an absolute path is read as it stands. @@ -53,6 +58,11 @@ #: How many distinct texts :func:`_parse` keeps. The workflow files, the same files #: as an older commit held them, and the copies a test doctors all fit. CACHE_SIZE = 128 +#: A ``uses:`` line pinned to a commit with its version comment, read from the raw +#: text: the action (``owner/repo`` and any sub-path), the 40-hex commit and the +#: version the comment names, which is what Dependabot rewrites on a bump. +PINNED = re.compile(r'(?m)^\s*(?:- )?uses: (?P[\w.-]+/[\w.-]+(?:/[^@\s]+)?)' + r'@(?P[0-9a-f]{40}) # (?Pv\d[\w.-]*)\s*$') @functools.lru_cache(maxsize=CACHE_SIZE) @@ -91,3 +101,14 @@ def job(path: 'PathLike', job_id: 'str') -> 'dict[str, Any]': def steps(path: 'PathLike', job_id: 'str') -> 'list[dict[str, Any]]': """The ``steps`` of job ``job_id`` of the workflow at ``path``, as :func:`load` reads it.""" return job(path, job_id)['steps'] + + +def pinned(uses: 'object', action: 'str') -> 'bool': + """Whether ``uses``, a parsed step's ``uses``, is ``action`` pinned to a full commit sha.""" + return re.fullmatch(rf'{re.escape(action)}@[0-9a-f]{{40}}', str(uses)) is not None + + +def versions(path: 'PathLike', action: 'str') -> 'list[str]': + """The version comment on each line of the workflow at ``path`` pinning ``action``, in order.""" + text = (WORKFLOWS / path).read_text(encoding='utf-8') + return [match['version'] for match in PINNED.finditer(text) if match['action'] == action] diff --git a/tests/project/test_actions_pinned_1968.py b/tests/project/test_actions_pinned_1968.py new file mode 100644 index 0000000000..6b41833963 --- /dev/null +++ b/tests/project/test_actions_pinned_1968.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""#1968: every action is pinned to a commit, and Dependabot keeps the pins current. + +A tag or a branch is a reference the action's owner can move, so ``uses: +owner/repo@v4`` runs whatever that reference names on the day of the run. Each +``uses:`` outside this repository therefore names a full commit and carries the +release that commit is as a trailing comment:: + + uses: actions/checkout@<40-hex sha> # v7.0.1 + +which is the form Dependabot's ``github-actions`` ecosystem rewrites on a bump, +commit and comment together. A local ``./`` reference and a ``docker://`` image +are not actions fetched by tag, so they are left alone. + +* :class:`TestEveryActionIsPinned` reads the raw text of every workflow and every + composite action under :file:`.github`, since YAML drops the comment. +* :class:`TestDependabotUpdatesTheActions` checks :file:`.github/dependabot.yml` + watches the ``github-actions`` ecosystem from the repository root. + +""" + +from __future__ import annotations + +import re +import unittest +from typing import TYPE_CHECKING + +from tests import _workflows +from tests._tiers import ROOT + +if TYPE_CHECKING: + import pathlib + +#: Test tier (#1958): checks the workflow files and Dependabot's configuration, a +#: question about the tree. +__tier__ = 'LATEST' + +#: Every ``uses:`` key, quoted or not, with whatever follows it on the line. +USES = re.compile(r'''(?m)^\s*(?:-\s+)?uses:\s*(?P['"]?[^\s'"#]+['"]?)(?P.*)$''') +#: What must follow a non-local reference's ``@``: a commit, then its version. +PIN = re.compile(r'[0-9a-f]{40}') +COMMENT = re.compile(r'\s+# v\d[\w.-]*\s*') +#: The Dependabot configuration. +DEPENDABOT = ROOT / '.github' / 'dependabot.yml' + + +def files() -> 'list[pathlib.Path]': + """The workflows, and any composite action, under :file:`.github`.""" + found = sorted(_workflows.WORKFLOWS.glob('*.yml')) + sorted(_workflows.WORKFLOWS.glob('*.yaml')) + for name in ('action.yml', 'action.yaml'): + found.extend(sorted((ROOT / '.github').rglob(name))) + return found + + +def unpinned(text: 'str') -> 'list[str]': + """Each non-local ``uses:`` line of ``text`` not pinned to a commit with a version comment.""" + bad = [] + for match in USES.finditer(text): + ref = match['ref'].strip('\'"') + if ref.startswith(('./', 'docker://')): + continue + _, _, version = ref.partition('@') + if PIN.fullmatch(version) is None or COMMENT.fullmatch(match['rest']) is None: + bad.append(match.group(0).strip()) + return bad + + +class TestEveryActionIsPinned(unittest.TestCase): + """No ``uses:`` outside this repository names a tag or a branch.""" + + def test_there_are_files_to_read(self) -> None: + self.assertTrue(files()) + + def test_every_action_is_a_commit_with_its_version(self) -> None: + for path in files(): + with self.subTest(file=path.name): + self.assertEqual(unpinned(path.read_text(encoding='utf-8')), []) + + def test_the_parsed_reference_is_the_commit(self) -> None: + """What GitHub runs is what YAML reads, and the comment is not part of it.""" + for path in sorted(_workflows.WORKFLOWS.glob('*.yml')): + for job_id, job in (_workflows.load(path).get('jobs') or {}).items(): + for entry in job.get('steps') or []: + uses = str(entry.get('uses', '')) + if not uses or uses.startswith(('./', 'docker://')): + continue + with self.subTest(file=path.name, job=job_id, uses=uses): + action, _, sha = uses.partition('@') + self.assertRegex(sha, r'^[0-9a-f]{40}$') + self.assertTrue(_workflows.pinned(uses, action)) + self.assertTrue(_workflows.versions(path, action)) + + def test_the_check_refuses_a_tag_a_branch_and_a_missing_comment(self) -> None: + sha = '0' * 40 + for line in ('- uses: actions/checkout@v7', + ' uses: pypa/gh-action-pypi-publish@release/v1', + f'- uses: actions/checkout@{sha}', + f'- uses: actions/checkout@{sha} # latest', + f'- uses: actions/checkout@{sha[:7]} # v7.0.1', + f"- uses: 'actions/checkout@v7' # v7.0.1"): + with self.subTest(line=line): + self.assertEqual(unpinned(line + '\n'), [line.strip()]) + for line in (f'- uses: actions/checkout@{sha} # v7.0.1', + f' uses: github/codeql-action/init@{sha} # v4.39.0', + ' uses: ./.github/workflows/unit-tests.yml', + ' uses: docker://alpine:3.20', + '# uses: some/action@master'): + with self.subTest(line=line): + self.assertEqual(unpinned(line + '\n'), []) + + +class TestDependabotUpdatesTheActions(unittest.TestCase): + """Dependabot watches the workflows as well as the Python dependencies.""" + + def test_the_github_actions_ecosystem_is_configured(self) -> None: + text = DEPENDABOT.read_text(encoding='utf-8') + ecosystems = re.findall(r'package-ecosystem:\s*"([^"]+)"', text) + self.assertIn('github-actions', ecosystems) + self.assertIn('pip', ecosystems) + + def test_it_reads_the_workflows_from_the_root(self) -> None: + """``directory: "/"`` is where Dependabot looks for :file:`.github/workflows`.""" + text = DEPENDABOT.read_text(encoding='utf-8') + entry = re.search(r'(?ms)package-ecosystem:\s*"github-actions".*?(?=^\s*-\s+package-ecosystem:|\Z)', text) + self.assertIsNotNone(entry) + self.assertRegex(entry.group(0), r'(?m)^\s*directory:\s*"/"') # type: ignore[union-attr] + self.assertRegex(entry.group(0), r'(?m)^\s*interval:\s*"\w+"') # type: ignore[union-attr] + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_ci_timing.py b/tests/project/test_ci_timing.py index c0a91233f4..c4c332923e 100644 --- a/tests/project/test_ci_timing.py +++ b/tests/project/test_ci_timing.py @@ -1129,7 +1129,7 @@ def test_every_unit_and_integration_leg_uploads_its_timings(self) -> 'None': self.assertNotIn('python -m pytest', block) def test_artifact_names_are_unique_in_a_run(self) -> 'None': - names = re.findall(r'uses: actions/upload-artifact@\S+\n +with:\n +name: ([^\n]+)', self.text) + names = re.findall(r'uses: actions/upload-artifact@\S+(?: # \S+)?\n +with:\n +name: ([^\n]+)', self.text) self.assertGreaterEqual(len(names), 5) self.assertEqual(len(names), len(set(names)), names) diff --git a/tests/project/test_conda_dist_keeps_release_notes_1657.py b/tests/project/test_conda_dist_keeps_release_notes_1657.py index 296023502e..c7c6240f2a 100644 --- a/tests/project/test_conda_dist_keeps_release_notes_1657.py +++ b/tests/project/test_conda_dist_keeps_release_notes_1657.py @@ -22,11 +22,14 @@ import unittest +from tests import _workflows from tests.project.test_release_tags_the_run_sha_1634 import (CONDA_WORKFLOW, UPLOAD_STEP, CondaDistMixin) -#: The pinned action. The input names and the citations above are for this tag. -ACTION = 'ncipollo/release-action@v1.21.0' +#: The pinned action, and the release its commit is (#1968). The input names and +#: the citations above are for this tag, so a bump means reading the action again. +ACTION = 'ncipollo/release-action' +VERSION = 'v1.21.0' #: The inputs that leave a field out of Update a release, and the field each keeps. OMITTED = { @@ -47,7 +50,8 @@ class TestTheUploadKeepsTheRelease(CondaDistMixin, unittest.TestCase): """Update a release leaves the name, body and flags as Create Release wrote them.""" def test_the_action_is_the_one_read(self) -> None: - self.assertEqual(self.upload().get('uses'), ACTION) + self.assertTrue(_workflows.pinned(self.upload().get('uses'), ACTION), self.upload().get('uses')) + self.assertEqual(_workflows.versions(CONDA_WORKFLOW, ACTION), [VERSION]) self.assertEqual(self.upload()['with'].get('allowUpdates'), 'true') def test_each_field_the_update_would_reset_is_omitted(self) -> None: diff --git a/tests/project/test_conventions_doc_claims.py b/tests/project/test_conventions_doc_claims.py index e3f436a2ab..b30bcc3488 100644 --- a/tests/project/test_conventions_doc_claims.py +++ b/tests/project/test_conventions_doc_claims.py @@ -1478,16 +1478,17 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': templated[template.name] = line.split(':', 1)[1].strip() # The dependabot attribution, which had no pin at all -- which is how the page - # came to name ``github_actions`` as dependabot-applied when - # .github/dependabot.yml configures only ``pip``, so dependabot can never open a - # workflow bump here. Saying a hand-applied label is a machine default is this - # section's own point stated backwards, and the places the page mentioned it - # disagreed with each other about ``python``. + # came to name ``github_actions`` as dependabot-applied while + # .github/dependabot.yml configured only ``pip``. Since #1968 it configures + # ``github-actions`` too, so the label is dependabot's on a workflow bump and + # hand-applied everywhere else. Getting either half wrong is this section's own + # point stated backwards, and the places the page mentioned it once disagreed + # with each other about ``python``. dependabot = (ROOT / '.github' / 'dependabot.yml').read_text(encoding='utf-8') ecosystems = re.findall(r'package-ecosystem:\s*"([^"]+)"', dependabot) self.assertEqual( - ecosystems, ['pip'], + ecosystems, ['pip', 'github-actions'], 'dependabot now watches a different set of ecosystems, so the page\'s claim ' f'about which labels it applies needs re-deriving: {ecosystems}') # Derived rather than pinned to one phrasing. The previous form asserted the @@ -1498,13 +1499,13 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': # the tool with the label is checked instead, so the phrasing no longer matters. # # Proximity is not the test, and a first attempt at #949 that used it was wrong: - # the section states more than once, deliberately, that ``github_actions`` is - # *not* dependabot's, and each of those sentences names the tool and the label - # together. + # the section then stated more than once, deliberately, that ``github_actions`` + # was *not* dependabot's, and each of those sentences named the tool and the + # label together. # Distinguishing an attribution from a denial by looking for a negation nearby - # also failed -- the attributing bullet's own sentence ends "so it never opens a - # workflow bump here", so the negation is present in the sentence that makes the - # claim as well as in the two that deny it. + # also failed -- the attributing bullet's own sentence ended "so it never opens a + # workflow bump here", so the negation was present in the sentence that made the + # claim as well as in the two that denied it. # # So the page's attribution is *parsed* instead: the one clause that says what # dependabot puts on its pull requests is located, the labels named inside it are @@ -1537,8 +1538,9 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': f'{claimed!r}') # And the positive half, which the set comparison above cannot reach: the page - # has to say outright that ``github_actions`` is hand-applied. Without this, a - # page that simply stopped mentioning the label would satisfy everything above. + # has to say outright that ``github_actions`` is hand-applied on every pull + # request dependabot did not open. Without this, a page that simply stopped + # mentioning the label would satisfy everything above. self.assertIn('hand-applied', self.flat, 'the page no longer says github_actions is hand-applied, so a ' 'reader is left to assume the label arrives automatically') diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py index 7a17dd9e78..a0ce75b888 100644 --- a/tests/project/test_python_versions_update_1660.py +++ b/tests/project/test_python_versions_update_1660.py @@ -1443,7 +1443,9 @@ def test_pr_mints_contents_and_pull_requests_write_behind_github_release(self) - self.assertEqual(job['environment'], 'github-release') self.assertEqual(job['needs'], 'detect') mint = self.step('pr', "Mint the release App's token") - self.assertEqual(mint['uses'], 'actions/create-github-app-token@v3') + self.assertTrue(_workflows.pinned(mint['uses'], 'actions/create-github-app-token'), mint['uses']) + self.assertEqual({version.split('.')[0] + for version in _workflows.versions(WORKFLOW, 'actions/create-github-app-token')}, {'v3'}) self.assertEqual(mint['with'], { 'client-id': '${{ vars.RELEASE_APP_CLIENT_ID }}', 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}', diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index d4e0aafb4a..ef4953b300 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -67,6 +67,7 @@ import unittest from typing import TYPE_CHECKING +from tests import _workflows from tests._support import scale_timeout from tests.project import test_release_gates as gates from tests.project.test_release_gates import (declared_environment, declared_if, evaluate_condition, @@ -98,7 +99,7 @@ #: ``github``'s step minting the release App's token, and its inputs, exactly. MINT_STEP = "Mint the release App's token" -MINT_ACTION = 'actions/create-github-app-token@v3' +MINT_ACTION = 'actions/create-github-app-token' MINT_WITH = { 'client-id': '${{ vars.RELEASE_APP_CLIENT_ID }}', 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}', @@ -369,7 +370,8 @@ def create_release(self) -> 'dict[str, Any]': def test_github_mints_with_contents_and_workflows_write_only(self) -> None: step = self.step('github', MINT_STEP) - self.assertEqual(step.get('uses'), MINT_ACTION) + self.assertTrue(_workflows.pinned(step.get('uses'), MINT_ACTION), step.get('uses')) + self.assertEqual({version.split('.')[0] for version in _workflows.versions(WORKFLOW, MINT_ACTION)}, {'v3'}) self.assertEqual(step.get('id'), 'app_token') self.assertEqual(step.get('with'), MINT_WITH) @@ -1541,7 +1543,8 @@ class TestCondaUpdateNeverMakesTheRelease(CondaDistMixin, unittest.TestCase): def test_the_action_is_still_the_upload(self) -> None: step = self.upload() - self.assertEqual(step.get('uses'), 'ncipollo/release-action@v1.21.0') + self.assertTrue(_workflows.pinned(step.get('uses'), 'ncipollo/release-action'), step.get('uses')) + self.assertEqual(_workflows.versions(CONDA_WORKFLOW, 'ncipollo/release-action'), ['v1.21.0']) self.assertEqual(step['with'].get('allowUpdates'), 'true') self.assertEqual(step['with'].get('tag'), CONDA_RELEASE_TAG) From 5bfb272794455149a1306c04b7c7f8a0b8e5d91c Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 12:02:57 -0400 Subject: [PATCH 2/4] ci: let Dependabot watch docs, conda and the benchmark, and retire PyUp's updates (#1968) - `pip` in `.github/dependabot.yml` now lists `directories:` `/`, `/docs`, `/conda` and `/examples/benchmark`; a `docker` entry watches `/examples/benchmark`. - `.pyup.yml` sets a global `update: False` and drops its `requirements:` list, whose per-file default of `all` would override it. - workflows.rst gains a Dependency Updates section; process.rst names the `docker` label and PyUp's opt-out. - `test_actions_pinned_1968` pins the pip and docker directories and the PyUp opt-out; `test_conventions_doc_claims` expects the `docker` ecosystem. --- .github/dependabot.yml | 14 +++- .pyup.yml | 15 ++-- .../contributing/conventions/process.rst | 19 ++--- docs/source/contributing/workflows.rst | 28 ++++++++ tests/project/test_actions_pinned_1968.py | 70 +++++++++++++++++++ tests/project/test_conventions_doc_claims.py | 5 +- 6 files changed, 133 insertions(+), 18 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ed682a5c5..3becc3ff9d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,11 +2,17 @@ # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# +# Dependabot replaces PyUp's update pull requests (#1968); `.pyup.yml` turns those off. version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values - directory: "/" # Location of package manifests + directories: # Location of package manifests; `directories` replaces `directory` + - "/" + - "/docs" # Pins Sphinx and sphinx-autodoc-typehints deliberately, bumps included + - "/conda" + - "/examples/benchmark" # Nothing else updates the benchmark's pins schedule: interval: "weekly" @@ -14,3 +20,9 @@ updates: directory: "/" # Dependabot reads .github/workflows from the repository root schedule: interval: "weekly" + + - package-ecosystem: "docker" # The benchmark's Dockerfile + directories: + - "/examples/benchmark" + schedule: + interval: "weekly" diff --git a/.pyup.yml b/.pyup.yml index 9f0eee7cd4..09b2c94d5a 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -1,9 +1,10 @@ -# autogenerated pyup.io config file +# pyup.io config file # see https://pyup.io/docs/configuration/ for all available options +# +# Dependabot opens the dependency updates now (#1968, see .github/dependabot.yml), +# so PyUp opens none. `update: False` is the global default every requirements file +# falls back to. No `requirements:` list is kept, because a file listed there takes +# its own `update`, which defaults to `all` and would override this. Deleting the +# file would not stop the bot either: without it, PyUp runs on its defaults. -schedule: every week - -requirements: - - docs/requirements.txt - - conda/requirements.txt: - update: False +update: False diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 91c542b29f..5cd7c043cd 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -158,12 +158,15 @@ it is a record that the owner asserted it. Some paths are automated, and each is worth knowing about because a label they set has had no human judgement behind it: -* **dependabot** puts ``dependencies``, ``python`` and ``github_actions`` on its own - pull requests, and only those: :file:`.github/dependabot.yml` configures the - ``pip`` and ``github-actions`` ecosystems and no ``labels:`` key, so each bump - takes dependabot's defaults -- ``dependencies`` always, with ``python`` on a - ``pip`` bump and ``github_actions`` on a workflow bump. On anyone else's pull - request ``github_actions`` is **hand-applied like the rest**. +* **dependabot** puts ``dependencies``, ``python``, ``github_actions`` and ``docker`` + on its own pull requests, and only those: :file:`.github/dependabot.yml` + configures the ``pip``, ``github-actions`` and ``docker`` ecosystems and no + ``labels:`` key, so each bump takes dependabot's defaults -- ``dependencies`` + always, with ``python`` on a ``pip`` bump, ``github_actions`` on a workflow bump + and ``docker`` on a base-image bump, which dependabot creates if the repository + lacks it. On anyone else's pull request ``github_actions`` is **hand-applied like + the rest**. PyUp opens no pull requests here: :file:`.pyup.yml` sets + ``update: False``. * **The issue templates** apply an issue-kind label from their front matter, before anyone reads the issue -- :file:`.github/ISSUE_TEMPLATE/bug_report.md` carries ``labels: bug`` and :file:`.github/ISSUE_TEMPLATE/feature_request.md` carries @@ -274,8 +277,8 @@ top of a review already in progress, and the second verdict then arrives against the first was never given. Separating the two makes *which pull requests still need a reviewer* answerable from the board rather than from memory of what was dispatched. -**Scope.** ``dependencies``, ``python`` and ``github_actions`` are dependabot's on -its own pull requests, per above. ``github_actions`` scopes a change to the workflows, +**Scope.** ``dependencies``, ``python``, ``github_actions`` and ``docker`` are +dependabot's on its own pull requests, per above. ``github_actions`` scopes a change to the workflows, and on any other pull request it is hand-applied. The ``breaking`` Label diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 6a45e22da2..6d3e42f8bf 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1102,6 +1102,34 @@ and when :file:`.github/dependabot.yml` drops the ``github-actions`` ecosystem. ``pinned()`` and ``versions()`` in :file:`tests/_workflows.py` read a pin, for a test that names a particular action. +Dependency Updates (`#1968 `__) +----------------------------------------------------------------------------------- + +Dependabot opens every dependency bump, weekly, from :file:`.github/dependabot.yml` +(the owner's ruling on #1968): + +* ``pip`` over ``/``, ``/docs``, ``/conda`` and ``/examples/benchmark``. The docs + build pins Sphinx and sphinx-autodoc-typehints exactly in + :file:`docs/requirements.txt`, and Dependabot proposes bumps to both pins; the + owner accepted that, so each bump is reviewed against the docs build rather than + held back. :file:`conda/requirements.txt` is also what Conda Update's ``Update + requirements.txt`` job rewrites through :file:`util/conda_vendor.py`, so that job's + next run supersedes a Dependabot bump there. The benchmark's + :file:`requirements-*.txt` files are watched because nothing else moves them. +* ``docker`` over ``/examples/benchmark``. Dependabot reads only a ``FROM`` line + that names its image, and the benchmark's :file:`Dockerfile` takes its base image + through ``ARG PYTHON_IMAGE``, whose digests live in + :file:`examples/benchmark/python-images.txt`. So this entry proposes nothing until + a ``FROM`` names an image itself, and those digests are still moved by hand. +* ``github-actions`` over ``/``, per `Action Pins`_ above. + +PyUp, which opened the same kind of bump before, opens none: :file:`.pyup.yml` sets +``update: False`` and lists no ``requirements:``. A file listed there takes its own +``update``, which defaults to ``all``, so the list would override the global +setting. Deleting :file:`.pyup.yml` would not stop the bot either, as without it +PyUp runs on its defaults. The PyUp App's installation, and the +``pyup.io/safety-ci`` commit status it posts, are repository settings. + Python Versions --------------- diff --git a/tests/project/test_actions_pinned_1968.py b/tests/project/test_actions_pinned_1968.py index 6b41833963..e84d576305 100644 --- a/tests/project/test_actions_pinned_1968.py +++ b/tests/project/test_actions_pinned_1968.py @@ -16,6 +16,12 @@ composite action under :file:`.github`, since YAML drops the comment. * :class:`TestDependabotUpdatesTheActions` checks :file:`.github/dependabot.yml` watches the ``github-actions`` ecosystem from the repository root. +* :class:`TestDependabotCoversEveryManifest` checks the owner's ruling on #1968: + ``pip`` over the root, :file:`docs`, :file:`conda` and + :file:`examples/benchmark`, and ``docker`` over the benchmark's + :file:`Dockerfile`. +* :class:`TestPyUpOpensNoUpdates` checks :file:`.pyup.yml` turns PyUp's update + pull requests off, now that Dependabot opens them. """ @@ -30,6 +36,7 @@ if TYPE_CHECKING: import pathlib + from typing import Any #: Test tier (#1958): checks the workflow files and Dependabot's configuration, a #: question about the tree. @@ -42,6 +49,23 @@ COMMENT = re.compile(r'\s+# v\d[\w.-]*\s*') #: The Dependabot configuration. DEPENDABOT = ROOT / '.github' / 'dependabot.yml' +#: PyUp's configuration. +PYUP = ROOT / '.pyup.yml' +#: The directories the ``pip`` entry watches, in the owner's ruling on #1968. +PIP_DIRECTORIES = ['/', '/docs', '/conda', '/examples/benchmark'] +#: The directories the ``docker`` entry watches. +DOCKER_DIRECTORIES = ['/examples/benchmark'] + + +def updates() -> 'dict[str, dict[str, Any]]': + """The entries of :file:`.github/dependabot.yml`, by ecosystem.""" + document = _workflows.loads(DEPENDABOT.read_text(encoding='utf-8')) + return {entry['package-ecosystem']: entry for entry in document['updates']} + + +def directories(entry: 'dict[str, Any]') -> 'list[str]': + """What an entry watches, whichever of ``directory`` and ``directories`` it uses.""" + return list(entry['directories']) if 'directories' in entry else [entry['directory']] def files() -> 'list[pathlib.Path]': @@ -127,5 +151,51 @@ def test_it_reads_the_workflows_from_the_root(self) -> None: self.assertRegex(entry.group(0), r'(?m)^\s*interval:\s*"\w+"') # type: ignore[union-attr] + +class TestDependabotCoversEveryManifest(unittest.TestCase): + """Dependabot watches the docs, conda and benchmark pins, and the benchmark image.""" + + def test_pip_watches_the_root_docs_conda_and_the_benchmark(self) -> None: + self.assertEqual(directories(updates()['pip']), PIP_DIRECTORIES) + + def test_each_pip_directory_holds_a_manifest(self) -> None: + for directory in PIP_DIRECTORIES: + with self.subTest(directory=directory): + here = ROOT / directory.lstrip('/') + self.assertTrue(sorted(here.glob('*requirements*.txt')) or (here / 'pyproject.toml').is_file()) + + def test_docker_watches_the_benchmark_dockerfile(self) -> None: + entry = updates().get('docker') + self.assertIsNotNone(entry) + self.assertEqual(directories(entry), DOCKER_DIRECTORIES) # type: ignore[arg-type] + for directory in DOCKER_DIRECTORIES: + with self.subTest(directory=directory): + self.assertTrue((ROOT / directory.lstrip('/') / 'Dockerfile').is_file()) + + def test_every_entry_is_weekly_and_names_one_kind_of_directory(self) -> None: + """The schema takes ``directory`` or ``directories``, never both.""" + for ecosystem, entry in updates().items(): + with self.subTest(ecosystem=ecosystem): + self.assertEqual(entry['schedule'], {'interval': 'weekly'}) + self.assertEqual(len({'directory', 'directories'} & set(entry)), 1) + + +class TestPyUpOpensNoUpdates(unittest.TestCase): + """PyUp's update pull requests are off, and nothing in its file turns one back on.""" + + def test_the_global_update_is_off(self) -> None: + config = _workflows.loads(PYUP.read_text(encoding='utf-8')) + self.assertIs(config.get('update'), False) + + def test_no_requirements_file_overrides_it(self) -> None: + """A listed file takes its own ``update``, which defaults to ``all``.""" + config = _workflows.loads(PYUP.read_text(encoding='utf-8')) + for item in config.get('requirements') or []: + with self.subTest(item=item): + self.assertIsInstance(item, dict) + (options,) = item.values() + self.assertIs(options.get('update'), False) + + if __name__ == '__main__': unittest.main() diff --git a/tests/project/test_conventions_doc_claims.py b/tests/project/test_conventions_doc_claims.py index b30bcc3488..b7a54e84f0 100644 --- a/tests/project/test_conventions_doc_claims.py +++ b/tests/project/test_conventions_doc_claims.py @@ -1488,7 +1488,7 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': ecosystems = re.findall(r'package-ecosystem:\s*"([^"]+)"', dependabot) self.assertEqual( - ecosystems, ['pip', 'github-actions'], + ecosystems, ['pip', 'github-actions', 'docker'], 'dependabot now watches a different set of ecosystems, so the page\'s claim ' f'about which labels it applies needs re-deriving: {ecosystems}') # Derived rather than pinned to one phrasing. The previous form asserted the @@ -1515,7 +1515,8 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': # compare the same -- and locating the clause is asserted rather than assumed, so # a rewording that this can no longer read fails loudly instead of passing. ECOSYSTEM_LABELS = {'pip': {'dependencies', 'python'}, - 'github-actions': {'dependencies', 'github_actions'}} + 'github-actions': {'dependencies', 'github_actions'}, + 'docker': {'dependencies', 'docker'}} expected = set().union(*(ECOSYSTEM_LABELS[eco] for eco in ecosystems)) # `findall`, not `search`: a first-match-only read checks one attributing From 452715fa34971a5089a949b7e898a2319d936205 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 12:21:32 -0400 Subject: [PATCH 3/4] ci: watch only the manifests Dependabot can update (#1968) - `.github/dependabot.yml` drops the `docker` entry and `/conda` from the `pip` directories, keeping `/`, `/docs` and `/examples/benchmark`, and says why the two are left out. - workflows.rst and process.rst match, and the timed wording in `.pyup.yml`, workflows.rst and the pin's docstring goes. - `test_actions_pinned_1968` pins the three pip directories and the absence of `/conda` and `docker`; `test_conventions_doc_claims` expects `pip` and `github-actions` only. --- .github/dependabot.yml | 10 ++---- .pyup.yml | 2 +- .../contributing/conventions/process.rst | 20 +++++------ docs/source/contributing/workflows.rst | 22 ++++++------ tests/project/test_actions_pinned_1968.py | 35 ++++++++++--------- tests/project/test_conventions_doc_claims.py | 5 ++- 6 files changed, 44 insertions(+), 50 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3becc3ff9d..59017ac8e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,9 @@ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # # Dependabot replaces PyUp's update pull requests (#1968); `.pyup.yml` turns those off. +# Left out (#1968): conda/requirements.txt, which Conda Update's `Refresh the +# platform pins` and `Setup Conda Distribution` steps write, and the benchmark's +# image digests, which its Dockerfile takes from examples/benchmark/python-images.txt. version: 2 updates: @@ -11,7 +14,6 @@ updates: directories: # Location of package manifests; `directories` replaces `directory` - "/" - "/docs" # Pins Sphinx and sphinx-autodoc-typehints deliberately, bumps included - - "/conda" - "/examples/benchmark" # Nothing else updates the benchmark's pins schedule: interval: "weekly" @@ -20,9 +22,3 @@ updates: directory: "/" # Dependabot reads .github/workflows from the repository root schedule: interval: "weekly" - - - package-ecosystem: "docker" # The benchmark's Dockerfile - directories: - - "/examples/benchmark" - schedule: - interval: "weekly" diff --git a/.pyup.yml b/.pyup.yml index 09b2c94d5a..e59768bf35 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -1,7 +1,7 @@ # pyup.io config file # see https://pyup.io/docs/configuration/ for all available options # -# Dependabot opens the dependency updates now (#1968, see .github/dependabot.yml), +# Dependabot opens the dependency updates (#1968, see .github/dependabot.yml), # so PyUp opens none. `update: False` is the global default every requirements file # falls back to. No `requirements:` list is kept, because a file listed there takes # its own `update`, which defaults to `all` and would override this. Deleting the diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 5cd7c043cd..c876b987fe 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -158,14 +158,12 @@ it is a record that the owner asserted it. Some paths are automated, and each is worth knowing about because a label they set has had no human judgement behind it: -* **dependabot** puts ``dependencies``, ``python``, ``github_actions`` and ``docker`` - on its own pull requests, and only those: :file:`.github/dependabot.yml` - configures the ``pip``, ``github-actions`` and ``docker`` ecosystems and no - ``labels:`` key, so each bump takes dependabot's defaults -- ``dependencies`` - always, with ``python`` on a ``pip`` bump, ``github_actions`` on a workflow bump - and ``docker`` on a base-image bump, which dependabot creates if the repository - lacks it. On anyone else's pull request ``github_actions`` is **hand-applied like - the rest**. PyUp opens no pull requests here: :file:`.pyup.yml` sets +* **dependabot** puts ``dependencies``, ``python`` and ``github_actions`` on its own + pull requests, and only those: :file:`.github/dependabot.yml` configures the + ``pip`` and ``github-actions`` ecosystems and no ``labels:`` key, so each bump + takes dependabot's defaults -- ``dependencies`` always, with ``python`` on a + ``pip`` bump and ``github_actions`` on a workflow bump. On anyone else's pull + request ``github_actions`` is **hand-applied like the rest**. PyUp opens no pull requests here: :file:`.pyup.yml` sets ``update: False``. * **The issue templates** apply an issue-kind label from their front matter, before anyone reads the issue -- :file:`.github/ISSUE_TEMPLATE/bug_report.md` carries @@ -277,9 +275,9 @@ top of a review already in progress, and the second verdict then arrives against the first was never given. Separating the two makes *which pull requests still need a reviewer* answerable from the board rather than from memory of what was dispatched. -**Scope.** ``dependencies``, ``python``, ``github_actions`` and ``docker`` are -dependabot's on its own pull requests, per above. ``github_actions`` scopes a change to the workflows, -and on any other pull request it is hand-applied. +**Scope.** ``dependencies``, ``python`` and ``github_actions`` are dependabot's on +its own pull requests, per above. ``github_actions`` scopes a change to the +workflows, and on any other pull request it is hand-applied. The ``breaking`` Label ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 6d3e42f8bf..694ca816d7 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1108,21 +1108,19 @@ Dependency Updates (`#1968 ` Dependabot opens every dependency bump, weekly, from :file:`.github/dependabot.yml` (the owner's ruling on #1968): -* ``pip`` over ``/``, ``/docs``, ``/conda`` and ``/examples/benchmark``. The docs - build pins Sphinx and sphinx-autodoc-typehints exactly in - :file:`docs/requirements.txt`, and Dependabot proposes bumps to both pins; the - owner accepted that, so each bump is reviewed against the docs build rather than - held back. :file:`conda/requirements.txt` is also what Conda Update's ``Update - requirements.txt`` job rewrites through :file:`util/conda_vendor.py`, so that job's - next run supersedes a Dependabot bump there. The benchmark's +* ``pip`` over ``/``, ``/docs`` and ``/examples/benchmark``. The docs build pins + Sphinx and sphinx-autodoc-typehints exactly in :file:`docs/requirements.txt`, and + Dependabot proposes bumps to both pins; the owner accepted that, so each bump is + reviewed against the docs build rather than held back. The benchmark's :file:`requirements-*.txt` files are watched because nothing else moves them. -* ``docker`` over ``/examples/benchmark``. Dependabot reads only a ``FROM`` line - that names its image, and the benchmark's :file:`Dockerfile` takes its base image - through ``ARG PYTHON_IMAGE``, whose digests live in - :file:`examples/benchmark/python-images.txt`. So this entry proposes nothing until - a ``FROM`` names an image itself, and those digests are still moved by hand. * ``github-actions`` over ``/``, per `Action Pins`_ above. +Neither :file:`conda/requirements.txt` nor the benchmark's image digests are +watched: Conda Update's ``Refresh the platform pins`` and ``Setup Conda +Distribution`` steps rewrite the former, and Dependabot reads only a ``FROM`` line +that names its image, while the benchmark's :file:`Dockerfile` takes its base image +through ``ARG PYTHON_IMAGE`` from :file:`examples/benchmark/python-images.txt`. + PyUp, which opened the same kind of bump before, opens none: :file:`.pyup.yml` sets ``update: False`` and lists no ``requirements:``. A file listed there takes its own ``update``, which defaults to ``all``, so the list would override the global diff --git a/tests/project/test_actions_pinned_1968.py b/tests/project/test_actions_pinned_1968.py index e84d576305..55735711c5 100644 --- a/tests/project/test_actions_pinned_1968.py +++ b/tests/project/test_actions_pinned_1968.py @@ -17,11 +17,11 @@ * :class:`TestDependabotUpdatesTheActions` checks :file:`.github/dependabot.yml` watches the ``github-actions`` ecosystem from the repository root. * :class:`TestDependabotCoversEveryManifest` checks the owner's ruling on #1968: - ``pip`` over the root, :file:`docs`, :file:`conda` and - :file:`examples/benchmark`, and ``docker`` over the benchmark's - :file:`Dockerfile`. + ``pip`` over the root, :file:`docs` and :file:`examples/benchmark`, and + neither :file:`conda` nor a ``docker`` entry, as Dependabot cannot update + either. * :class:`TestPyUpOpensNoUpdates` checks :file:`.pyup.yml` turns PyUp's update - pull requests off, now that Dependabot opens them. + pull requests off, as Dependabot opens them. """ @@ -52,9 +52,7 @@ #: PyUp's configuration. PYUP = ROOT / '.pyup.yml' #: The directories the ``pip`` entry watches, in the owner's ruling on #1968. -PIP_DIRECTORIES = ['/', '/docs', '/conda', '/examples/benchmark'] -#: The directories the ``docker`` entry watches. -DOCKER_DIRECTORIES = ['/examples/benchmark'] +PIP_DIRECTORIES = ['/', '/docs', '/examples/benchmark'] def updates() -> 'dict[str, dict[str, Any]]': @@ -153,9 +151,9 @@ def test_it_reads_the_workflows_from_the_root(self) -> None: class TestDependabotCoversEveryManifest(unittest.TestCase): - """Dependabot watches the docs, conda and benchmark pins, and the benchmark image.""" + """Dependabot watches the docs and benchmark pins, and nothing it cannot update.""" - def test_pip_watches_the_root_docs_conda_and_the_benchmark(self) -> None: + def test_pip_watches_the_root_docs_and_the_benchmark(self) -> None: self.assertEqual(directories(updates()['pip']), PIP_DIRECTORIES) def test_each_pip_directory_holds_a_manifest(self) -> None: @@ -164,13 +162,18 @@ def test_each_pip_directory_holds_a_manifest(self) -> None: here = ROOT / directory.lstrip('/') self.assertTrue(sorted(here.glob('*requirements*.txt')) or (here / 'pyproject.toml').is_file()) - def test_docker_watches_the_benchmark_dockerfile(self) -> None: - entry = updates().get('docker') - self.assertIsNotNone(entry) - self.assertEqual(directories(entry), DOCKER_DIRECTORIES) # type: ignore[arg-type] - for directory in DOCKER_DIRECTORIES: - with self.subTest(directory=directory): - self.assertTrue((ROOT / directory.lstrip('/') / 'Dockerfile').is_file()) + def test_conda_is_not_watched(self) -> None: + """Conda Update's ``Refresh the platform pins`` and ``Setup Conda Distribution`` write it.""" + for ecosystem, entry in updates().items(): + with self.subTest(ecosystem=ecosystem): + self.assertNotIn('/conda', directories(entry)) + + def test_there_is_no_docker_entry(self) -> None: + """Dependabot reads a ``FROM`` that names its image; the benchmark's is an ``ARG``. + + Its digests live in :file:`examples/benchmark/python-images.txt`. + """ + self.assertNotIn('docker', updates()) def test_every_entry_is_weekly_and_names_one_kind_of_directory(self) -> None: """The schema takes ``directory`` or ``directories``, never both.""" diff --git a/tests/project/test_conventions_doc_claims.py b/tests/project/test_conventions_doc_claims.py index b7a54e84f0..b30bcc3488 100644 --- a/tests/project/test_conventions_doc_claims.py +++ b/tests/project/test_conventions_doc_claims.py @@ -1488,7 +1488,7 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': ecosystems = re.findall(r'package-ecosystem:\s*"([^"]+)"', dependabot) self.assertEqual( - ecosystems, ['pip', 'github-actions', 'docker'], + ecosystems, ['pip', 'github-actions'], 'dependabot now watches a different set of ecosystems, so the page\'s claim ' f'about which labels it applies needs re-deriving: {ecosystems}') # Derived rather than pinned to one phrasing. The previous form asserted the @@ -1515,8 +1515,7 @@ def test_the_page_says_the_labels_are_set_by_hand(self) -> 'None': # compare the same -- and locating the clause is asserted rather than assumed, so # a rewording that this can no longer read fails loudly instead of passing. ECOSYSTEM_LABELS = {'pip': {'dependencies', 'python'}, - 'github-actions': {'dependencies', 'github_actions'}, - 'docker': {'dependencies', 'docker'}} + 'github-actions': {'dependencies', 'github_actions'}} expected = set().union(*(ECOSYSTEM_LABELS[eco] for eco in ecosystems)) # `findall`, not `search`: a first-match-only read checks one attributing From 1aa2186f3b99507df732511ee59a16a65cd8b42f Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 13:02:57 -0400 Subject: [PATCH 4/4] test(project): let the apt-cache pins read SHA-pinned actions (#1968) - `test_workflow_apt_cache` finds the `actions/cache/restore` and `actions/cache/save` steps through `_workflows.PINNED`, so the pinned `@ # vX.Y.Z` form is read instead of failing as `None`. - `test_ci_timing` reads its `actions/upload-artifact` steps the same way rather than with a regex of its own. --- tests/project/test_ci_timing.py | 6 +++++- tests/project/test_workflow_apt_cache.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/project/test_ci_timing.py b/tests/project/test_ci_timing.py index c4c332923e..6809537d4f 100644 --- a/tests/project/test_ci_timing.py +++ b/tests/project/test_ci_timing.py @@ -52,6 +52,7 @@ import requests +from tests import _workflows from tests.project.test_python_versions_1660 import NO_TOMLLIB from tests.project.test_python_versions_1660 import legs as matrix_legs from tests.project.test_python_versions_1660 import matrix, tomllib @@ -1129,7 +1130,10 @@ def test_every_unit_and_integration_leg_uploads_its_timings(self) -> 'None': self.assertNotIn('python -m pytest', block) def test_artifact_names_are_unique_in_a_run(self) -> 'None': - names = re.findall(r'uses: actions/upload-artifact@\S+(?: # \S+)?\n +with:\n +name: ([^\n]+)', self.text) + names = [found.group(1) for found in + (re.match(r'\n? +with:\n +name: ([^\n]+)', self.text[pin.end():]) + for pin in _workflows.PINNED.finditer(self.text) if pin['action'] == 'actions/upload-artifact') + if found is not None] self.assertGreaterEqual(len(names), 5) self.assertEqual(len(names), len(set(names)), names) diff --git a/tests/project/test_workflow_apt_cache.py b/tests/project/test_workflow_apt_cache.py index 44c052424a..d6dd5dbb6d 100644 --- a/tests/project/test_workflow_apt_cache.py +++ b/tests/project/test_workflow_apt_cache.py @@ -37,6 +37,7 @@ import unittest from typing import TYPE_CHECKING +from tests import _workflows from tests.project.test_python_versions_1660 import legs, matrix from tests.project.test_unit_tests_queue import job, step_script @@ -198,6 +199,14 @@ def matrix_engines() -> 'list[str]': return re.findall(r'- (\S+)', match.group(1)) +def with_block(text: 'str', action: 'str') -> 'Optional[re.Match[str]]': + """The ``with:`` block of the step in ``text`` running ``action``, pinned to a commit (#1968).""" + pin = next((match for match in _workflows.PINNED.finditer(text) if match['action'] == action), None) + if pin is None: + return None + return re.match(r'(?s)\n? with:\n(.*?)\n\n', text[pin.end():]) + + def job_env(job_name: 'str', key: 'str') -> 'str': """The raw value of ``key`` in ``job_name``'s job-level ``env:``.""" match = re.search(rf'(?m)^ {re.escape(key)}: (.+)$', job(job_name)) @@ -297,8 +306,8 @@ def test_restore_and_save_use_the_plan_and_the_same_path(self) -> None: for job_name, _, _ in SITES: with self.subTest(job=job_name): text = job(job_name) - restore = re.search(r'(?ms)uses: actions/cache/restore@\S+\n with:\n(.*?)\n\n', text) - save = re.search(r'(?ms)uses: actions/cache/save@\S+\n with:\n(.*?)\n\n', text) + restore = with_block(text, 'actions/cache/restore') + save = with_block(text, 'actions/cache/save') self.assertIsNotNone(restore) self.assertIsNotNone(save) restore_with = dict(re.findall(r'(?m)^ ([\w-]+): (.+)$', restore.group(1))) # type: ignore[union-attr]