From fc192dd877ca8e5ccf8623cc0790feb71d7c93aa Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Tue, 20 Oct 2026 18:41:32 -0400 Subject: [PATCH] ci: share one reusable head across the cron workflows (#1967) - Conda Update, Vendor Update and GitHub Pages each carried the same test gate and `verdict` job. Both now live in cron-head.yml, a `workflow_call` workflow that each calls as its `head` job; the acting jobs read `needs.head.result` and `needs.head.outputs.verified`. - The Saturday schedules are spread eight hours apart (Pages 02:00, Vendor 10:00, Conda 18:00 UTC), so no two heads gate the same commit at once and Conda Update follows Vendor Update's release. - Triggers otherwise, concurrency groups, environments, deploy-key steps and permissions are unchanged; the head holds `contents: read` only. - Tests: the verdict and conda gate tables decide the head's jobs against cron-head.yml; new tests pin that the three call one head and the spread. - Docs: workflows.rst gains the Cron Head row, node, calls and schedule rationale, cited by name; pep.rst drops a line citation. --- .github/workflows/ci-timing.yml | 3 +- .github/workflows/cron-conda.yml | 109 ++++----- .github/workflows/cron-head.yml | 75 ++++++ .github/workflows/cron-vendor.yml | 89 +++---- .github/workflows/deploy-pages.yml | 92 +++----- .github/workflows/project-status.yml | 4 +- docs/source/contributing/pep.rst | 4 +- docs/source/contributing/releasing.rst | 10 +- docs/source/contributing/workflows.rst | 105 ++++++--- tests/project/test_release_gates.py | 74 +++++- tests/project/test_workflow_hardening_1968.py | 25 +- tests/project/test_workflow_run_verdict.py | 223 +++++++++++++----- 12 files changed, 501 insertions(+), 312 deletions(-) create mode 100644 .github/workflows/cron-head.yml diff --git a/.github/workflows/ci-timing.yml b/.github/workflows/ci-timing.yml index d7c56b149b..c45f670786 100644 --- a/.github/workflows/ci-timing.yml +++ b/.github/workflows/ci-timing.yml @@ -15,7 +15,8 @@ name: CI Timing on: schedule: # Mondays 07:23 UTC: off the hour, where GitHub delays scheduled runs most, - # and clear of Saturday's 02:00-10:00 crons and Project Status at 00:37. + # and clear of Saturday's crons, 02:00 to 18:00 since #1967, and Project + # Status at 00:37. - cron: '23 7 * * 1' workflow_dispatch: inputs: diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 0bfd3d0214..7b30a53151 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -4,12 +4,18 @@ permissions: contents: read on: + # #1967: GitHub Pages, Vendor Update and Conda Update run eight hours + # apart on Saturdays, so each runs `head`'s test gate alone, mostly on a + # commit of its own, and `vendor-update` and `conda-update` do not queue + # on `repository-maintenance` together. None reads another's output, and + # Conda Update comes last so its upload finds the release Create Release + # made after Vendor Update (#1634). workflows.rst gives the evidence. schedule: - - cron: '0 10 * * 6' # Saturdays at 10:00 UTC + - cron: '0 18 * * 6' # Saturdays at 18:00 UTC # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger - # already runs the full matrix for this commit; calling it again below with - # `gate-only: true` re-ran the single-version "Gate" job a second time for - # the same tree (#715). Chaining off `Unit Tests`' completion means the gate + # already runs the full matrix for this commit; calling it again on every push + # with `gate-only: true` re-ran the single-version "Gate" job a second time + # for the same tree (#715). Chaining off `Unit Tests`' completion means the gate # never runs twice, and `conda-update` below just reads the verdict that # already exists. `.event == 'push'` is checked in that job's `if` rather # than here, because `workflow_run` fires for a pull request's completion @@ -25,7 +31,7 @@ on: # # A `workflow_run` is keyed on the commit `Unit Tests` verified instead (#1643). # Each `Unit Tests` completion raises a run here, and one whose verdict is for an -# older commit only runs `verdict` and skips -- but in a shared group it would +# older commit only runs `head` and skips -- but in a shared group it would # still displace a pending run whose verdict matches, since "any existing pending # job or workflow in the same concurrency group will be canceled" (GitHub, # "Control the concurrency of workflows and jobs"). @@ -38,53 +44,14 @@ concurrency: cancel-in-progress: false jobs: - # Only earns its runners on the schedule trigger: `Unit Tests` has no - # schedule trigger of its own, so Saturday's cron run is the one case left - # where nothing else has already verified this tree. `workflow_run` carries - # its own verdict instead -- see `conda-update` below. - unit-tests: - name: Conda test gate - if: ${{ github.event_name != 'workflow_run' }} + # #1967: the test gate, on the schedule only, and the `verdict` on a + # `workflow_run`, shared with Vendor Update and GitHub Pages. cron-head.yml + # says when each runs. + head: + name: Conda gate permissions: contents: read - uses: ./.github/workflows/unit-tests.yml - with: - gate-only: true - - # #1643: `github.sha`, which every checkout here takes, is main's tip when the - # `workflow_run` was raised -- not the commit the completed `Unit Tests` run - # verified, and in a merge wave the two differ. This job compares them with - # util/verdict_covers_checkout.py, whose docstring gives the rule, and the job - # below acts on a `workflow_run` only when `verified` is 'true'. Its `if:` is - # that job's `workflow_run` clause, so a run raised by a pull request's or a - # failed `Unit Tests` run still concludes `skipped`, as before. - verdict: - name: Verdict covers this commit - if: >- - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - outputs: - verified: ${{ steps.verdict.outputs.verified }} - steps: - # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 20 - persist-credentials: false - - - name: Compare the verified commit with this checkout - id: verdict - env: - VERDICT_SHA: ${{ github.event.workflow_run.head_sha }} - run: python3 util/verdict_covers_checkout.py + uses: ./.github/workflows/cron-head.yml conda-update: name: Update requirements.txt @@ -93,27 +60,27 @@ jobs: # a job naming the environment can read it. Keep that environment free of # required reviewers, or every scheduled bump waits for an approval. environment: conda-tag - needs: [ unit-tests, verdict ] - # `needs` cannot itself be conditional, so a skipped gate (`workflow_run`) - # has to be accepted explicitly -- by default this job would inherit the - # skip. `!cancelled()` rather than `always()` so that cancelling the run - # still cancels the update; a gate that actually failed still blocks. + needs: [ head ] + # `needs` cannot itself be conditional, and `head`'s jobs skip on every + # path, so this job must not inherit a skip. `!cancelled()` rather than + # `always()` so that cancelling the run still cancels the update; a gate + # that actually failed still blocks. # # The `workflow_run` clause is the real gate for a push to main: it # reads the verdict `Unit Tests` already reached instead of re-running it, - # and `verdict` says whether that verdict covers the commit checked out - # below (#1643). `.event == 'push'` and `.head_branch == 'main'` + # and `head`'s `verified` says whether that verdict covers the commit + # checked out below (#1643). `.event == 'push'` and `.head_branch == 'main'` # both matter -- `workflow_run` fires for *every* completion of that # workflow, including one triggered by a pull request, and without this # check a green PR would upload a conda distribution built from that PR's # tree. if: >- - !cancelled() && needs.unit-tests.result != 'failure' && + !cancelled() && needs.head.result != 'failure' && (github.event_name != 'workflow_run' || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && - needs.verdict.outputs.verified == 'true' + needs.head.outputs.verified == 'true' )) concurrency: group: repository-maintenance @@ -140,7 +107,7 @@ jobs: # onto main's actual tip before writing, or does not write at all, so # pinning to the exact tested sha would have bought nothing here # either. Whether this commit is one that verdict covers is - # `verdict`'s question (#1643); whether what lands after it is, Push + # `head`'s question (#1643); whether what lands after it is, Push # Changes' (#1632). # # The push below re-points `origin` at an SSH remote and authenticates @@ -232,10 +199,11 @@ jobs: # Writes `pushed` and `commit`, which `CONDA_CHANGED` and `CONDA_COMMIT` # below hand to `conda-tag` and `conda-dist` (#1632). # - # The verdict this run acted on -- `verdict`'s, or on the schedule the - # gate's -- covers `$GITHUB_SHA`, the checkout, and nothing after it. A - # commit that lands on `main` during the run would otherwise be rebased - # under the bump, pushed, tagged and uploaded with no verdict of its own. + # The verdict this run acted on -- `head`'s `verdict`, or on the schedule + # its test gate's -- covers `$GITHUB_SHA`, the checkout, and nothing after + # it. A commit that lands on `main` during the run would otherwise be + # rebased under the bump, pushed, tagged and uploaded with no verdict of + # its own. # So the tip is fetched first and the commits a rebase onto it would bring # in are put to #1643's rule, util/verdict_covers_checkout.py, run from # this checkout and not from the tip it is judging. If they are not @@ -351,11 +319,14 @@ jobs: # required reviewers, or every scheduled bump waits for an approval. environment: conda-tag needs: [ conda-update ] - # `!cancelled()` is what makes this job reachable at all on the - # `workflow_run` path (#1632). `unit-tests` above skips on that event, and - # "a default status check of `success()` is applied unless you include one - # of these functions" (GitHub, "Evaluate expressions in workflows and - # actions", Status check functions). The skip it reacts to is not only a + # `!cancelled()` is what made this job reachable at all on the + # `workflow_run` path (#1632), when the test gate was a job of this file and + # skipped on that event. It is kept now that the gate is in `head` (#1967): + # a job of `head` skips on every path, and this job must not hinge on how + # Actions reports such a call. Without a status-check function, "a default + # status check of `success()` is applied unless you include one of these + # functions" (GitHub, "Evaluate expressions in workflows and actions", + # Status check functions). The skip it reacts to is not only a # direct `needs:` entry's: "If a run contains a series of jobs that need # each other, a failure or skip applies to all jobs in the dependency chain # from the point of failure or skip onwards" (GitHub, "Workflow syntax for diff --git a/.github/workflows/cron-head.yml b/.github/workflows/cron-head.yml new file mode 100644 index 0000000000..9f236d2024 --- /dev/null +++ b/.github/workflows/cron-head.yml @@ -0,0 +1,75 @@ +name: Cron Head + +# #1967: the head that Conda Update, Vendor Update and GitHub Pages each ran as +# two jobs of their own, a test gate and a `verdict`, kept in step by a test +# that compared the three copies. Each now calls this file as its `head` job and +# gates its own work on the call's result and its `verified` output. One task: +# saying whether the commit the caller checked out may be acted on. +# +# Reusable only. Every expression here reads the *caller's* `github` context, +# so `github.event_name` is the event that started Conda Update, Vendor Update +# or GitHub Pages, and `github.sha` is the commit it checked out. +on: + workflow_call: + outputs: + verified: + description: >- + 'true' when the `workflow_run` that raised the caller carries a + Unit Tests verdict covering the commit it checked out, 'false' when + it does not, and unset on any other event, where `verdict` skips. + value: ${{ jobs.verdict.outputs.verified }} + +# A called workflow cannot hold more than its caller grants, and each caller +# grants this one `contents: read`. That is also what the `unit-tests.yml` call +# below is granted, as Create Release's gate is. +permissions: + contents: read + +jobs: + # Only earns its runners when no completed Unit Tests run has verified this + # tree: on the Saturday schedule, since Unit Tests has no schedule trigger of + # its own. A `workflow_run` carries its own verdict, read by `verdict` below, + # and a pull request never acts, so both skip the gate (#715). + unit-tests: + name: Test gate + if: ${{ github.event_name != 'pull_request' && github.event_name != 'workflow_run' }} + permissions: + contents: read + uses: ./.github/workflows/unit-tests.yml + with: + gate-only: true + + # #1643: `github.sha`, which every checkout of the caller takes, is main's tip + # when the `workflow_run` was raised -- not the commit the completed `Unit + # Tests` run verified, and in a merge wave the two differ. This job compares + # them with util/verdict_covers_checkout.py, whose docstring gives the rule, + # and the caller acts on a `workflow_run` only when `verified` is 'true'. Its + # `if:` is the caller's `workflow_run` clause, so for a run raised by a pull + # request's or a failed `Unit Tests` run nothing here runs, as before. + verdict: + name: Verdict covers this commit + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + verified: ${{ steps.verdict.outputs.verified }} + steps: + # The default ref, as the caller's acting job 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 20 + persist-credentials: false + + - name: Compare the verified commit with this checkout + id: verdict + env: + VERDICT_SHA: ${{ github.event.workflow_run.head_sha }} + run: python3 util/verdict_covers_checkout.py diff --git a/.github/workflows/cron-vendor.yml b/.github/workflows/cron-vendor.yml index b4d668873e..3323fca633 100644 --- a/.github/workflows/cron-vendor.yml +++ b/.github/workflows/cron-vendor.yml @@ -1,12 +1,18 @@ name: "Vendor Update" on: + # #1967: GitHub Pages, Vendor Update and Conda Update run eight hours + # apart on Saturdays, so each runs `head`'s test gate alone, mostly on a + # commit of its own, and `vendor-update` and `conda-update` do not queue + # on `repository-maintenance` together. None reads another's output, and + # Conda Update comes last so its upload finds the release Create Release + # made after Vendor Update (#1634). workflows.rst gives the evidence. schedule: - cron: '0 10 * * 6' # Saturdays at 10:00 UTC # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger - # already runs the full matrix for this commit; calling it again below with - # `gate-only: true` re-ran the single-version "Gate" job a second time for - # the same tree (#715). Chaining off `Unit Tests`' completion means the gate + # already runs the full matrix for this commit; calling it again on every push + # with `gate-only: true` re-ran the single-version "Gate" job a second time + # for the same tree (#715). Chaining off `Unit Tests`' completion means the gate # never runs twice, and `vendor-update` below just reads the verdict that # already exists. `.event == 'push'` is checked in that job's `if` rather # than here, because `workflow_run` fires for a pull request's completion @@ -31,7 +37,7 @@ permissions: {} # # A `workflow_run` is keyed on the commit `Unit Tests` verified instead (#1643). # Each `Unit Tests` completion raises a run here, and one whose verdict is for an -# older commit only runs `verdict` and skips -- but in a shared group it would +# older commit only runs `head` and skips -- but in a shared group it would # still displace a pending run whose verdict matches, since "any existing pending # job or workflow in the same concurrency group will be canceled" (GitHub, # "Control the concurrency of workflows and jobs"). @@ -44,53 +50,14 @@ concurrency: cancel-in-progress: false jobs: - # Only earns its runners on the schedule trigger: `Unit Tests` has no - # schedule trigger of its own, so Saturday's cron run is the one case left - # where nothing else has already verified this tree. `workflow_run` carries - # its own verdict instead -- see `vendor-update` below. - unit-tests: - name: Vendor test gate - if: ${{ github.event_name != 'workflow_run' }} + # #1967: the test gate, on the schedule only, and the `verdict` on a + # `workflow_run`, shared with Conda Update and GitHub Pages. cron-head.yml + # says when each runs. + head: + name: Vendor gate permissions: contents: read - uses: ./.github/workflows/unit-tests.yml - with: - gate-only: true - - # #1643: `github.sha`, which every checkout here takes, is main's tip when the - # `workflow_run` was raised -- not the commit the completed `Unit Tests` run - # verified, and in a merge wave the two differ. This job compares them with - # util/verdict_covers_checkout.py, whose docstring gives the rule, and the job - # below acts on a `workflow_run` only when `verified` is 'true'. Its `if:` is - # that job's `workflow_run` clause, so a run raised by a pull request's or a - # failed `Unit Tests` run still concludes `skipped`, as before. - verdict: - name: Verdict covers this commit - if: >- - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - outputs: - verified: ${{ steps.verdict.outputs.verified }} - steps: - # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 20 - persist-credentials: false - - - name: Compare the verified commit with this checkout - id: verdict - env: - VERDICT_SHA: ${{ github.event.workflow_run.head_sha }} - run: python3 util/verdict_covers_checkout.py + uses: ./.github/workflows/cron-head.yml vendor-update: runs-on: macos-latest @@ -98,26 +65,26 @@ jobs: # a job naming the environment can read it. Keep that environment free of # required reviewers, or every scheduled bump waits for an approval. environment: conda-tag - needs: [ unit-tests, verdict ] - # `needs` cannot itself be conditional, so a skipped gate (`workflow_run`) - # has to be accepted explicitly -- by default this job would inherit the - # skip. `!cancelled()` rather than `always()` so that cancelling the run - # still cancels the update; a gate that actually failed still blocks. + needs: [ head ] + # `needs` cannot itself be conditional, and `head`'s jobs skip on every + # path, so this job must not inherit a skip. `!cancelled()` rather than + # `always()` so that cancelling the run still cancels the update; a gate + # that actually failed still blocks. # # The `workflow_run` clause is the real gate for a push to main: it # reads the verdict `Unit Tests` already reached instead of re-running it, - # and `verdict` says whether that verdict covers the commit checked out - # below (#1643). `.event == 'push'` and `.head_branch == 'main'` + # and `head`'s `verified` says whether that verdict covers the commit + # checked out below (#1643). `.event == 'push'` and `.head_branch == 'main'` # both matter -- `workflow_run` fires for *every* completion of that # workflow, including one triggered by a pull request, and without this # check a green PR would push a vendor-registry commit from that PR's tree. if: >- - !cancelled() && needs.unit-tests.result != 'failure' && + !cancelled() && needs.head.result != 'failure' && (github.event_name != 'workflow_run' || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && - needs.verdict.outputs.verified == 'true' + needs.head.outputs.verified == 'true' )) concurrency: group: repository-maintenance @@ -143,7 +110,7 @@ jobs: # relying on CodeQL tracing through the guard. The push below rebases # onto main's actual tip before writing regardless, so pinning to the # exact tested sha would have bought nothing here either. Whether this - # commit is one that verdict covers is `verdict`'s question (#1643). + # commit is one that verdict covers is `head`'s question (#1643). # The push below re-points `origin` at an SSH remote and authenticates # with the loaded deploy key, not with this checkout's credential -- # so there is nothing here for a persisted token to do, and one less @@ -255,8 +222,8 @@ jobs: # still skips `pcapkit/const/__init__.py` itself. isort -l100 -ppcapkit $(find pcapkit/const -mindepth 2 -name '*.py') - # #1853: neither gate above sees the regenerated tree -- `unit-tests` runs - # on the schedule alone, and `verdict` accepts the Unit Tests result for + # #1853: neither gate in `head` sees the regenerated tree -- the test gate + # runs on the schedule alone, and `verdict` accepts the Unit Tests result for # the commit from before the crawl -- so 1.5.0b13 was committed with a test # failing on it. The owner's ruling: test the regenerated tree here, before # anything is committed, under #872's rule above. A module the tests fail diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 91bf7873c8..e6d3d0af2f 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -3,8 +3,8 @@ name: GitHub Pages on: # No `push: branches: [main]` here. `unit-tests.yml`'s own push trigger already # runs the full matrix for this commit; calling it again with `gate-only: true` - # below re-ran the single-version "Gate" job a second time for the same tree - # (#715). Chaining off `Unit Tests`' completion instead means the gate never + # on every push re-ran the single-version "Gate" job a second time for the same + # tree (#715). Chaining off `Unit Tests`' completion instead means the gate never # runs twice, and this workflow simply waits for the verdict that already # exists. `workflow_run` fires for *any* completion of that workflow, PR or # push, so the jobs below filter on `.event == 'push'` themselves rather than @@ -12,8 +12,14 @@ on: workflow_run: workflows: ["Unit Tests"] types: [completed] + # #1967: GitHub Pages, Vendor Update and Conda Update run eight hours + # apart on Saturdays, so each runs `head`'s test gate alone, mostly on a + # commit of its own, and `vendor-update` and `conda-update` do not queue + # on `repository-maintenance` together. None reads another's output, and + # Conda Update comes last so its upload finds the release Create Release + # made after Vendor Update (#1634). workflows.rst gives the evidence. schedule: - - cron: '0 2 * * 6' + - cron: '0 2 * * 6' # Saturdays at 02:00 UTC pull_request: # Was '**', which matched pull requests against every branch and made this # the one workflow that fired outside the main-targeting set the others @@ -31,7 +37,7 @@ permissions: # deployment, so serialising here is a requirement rather than a saving. # # A `workflow_run` is keyed on the commit `Unit Tests` verified instead (#1643). -# One whose verdict is for an older commit only runs `verdict` and skips, and in +# One whose verdict is for an older commit only runs `head` and skips, and in # the shared group it would still displace a pending run whose verdict matches, # since "any existing pending job or workflow in the same concurrency group will # be canceled" (GitHub, "Control the concurrency of workflows and jobs"). The @@ -42,77 +48,37 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Only earns its runners on the schedule trigger: `Unit Tests` has no - # schedule trigger of its own, so Saturday's cron run is the one case left - # where nothing else has already verified this tree, and a gate is still - # needed. Pull requests never published anyway, and `workflow_run` carries - # its own verdict from the run that completed -- see `deploy-pages` below. - unit-tests: - name: Docs test gate - if: ${{ github.event_name != 'pull_request' && github.event_name != 'workflow_run' }} + # #1967: the test gate, on the schedule only, and the `verdict` on a + # `workflow_run`, shared with Conda Update and Vendor Update. Pull requests + # never published anyway, so on one both skip. cron-head.yml says when each + # runs. + head: + name: Docs gate permissions: contents: read - uses: ./.github/workflows/unit-tests.yml - with: - gate-only: true - - # #1643: `github.sha`, which every checkout here takes, is main's tip when the - # `workflow_run` was raised -- not the commit the completed `Unit Tests` run - # verified, and in a merge wave the two differ. This job compares them with - # util/verdict_covers_checkout.py, whose docstring gives the rule, and the job - # below acts on a `workflow_run` only when `verified` is 'true'. Its `if:` is - # that job's `workflow_run` clause, so a run raised by a pull request's or a - # failed `Unit Tests` run still concludes `skipped`, as before. - verdict: - name: Verdict covers this commit - if: >- - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - outputs: - verified: ${{ steps.verdict.outputs.verified }} - steps: - # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 20 - persist-credentials: false - - - name: Compare the verified commit with this checkout - id: verdict - env: - VERDICT_SHA: ${{ github.event.workflow_run.head_sha }} - run: python3 util/verdict_covers_checkout.py + uses: ./.github/workflows/cron-head.yml deploy-pages: - needs: [ unit-tests, verdict ] - # `needs` cannot itself be conditional, so a skipped gate (pull requests, - # and `workflow_run`) has to be accepted explicitly -- by default this - # job would inherit the skip. `!cancelled()` rather than `always()` so that - # cancelling the run still cancels the docs build; a gate that actually - # failed still blocks. + needs: [ head ] + # `needs` cannot itself be conditional, and `head`'s jobs skip on every + # path (both on a pull request), so this job must not inherit a skip. + # `!cancelled()` rather than `always()` so that cancelling the run still + # cancels the docs build; a gate that actually failed still blocks. # # The `workflow_run` clause is the real gate for a push to main: it # reads the verdict `Unit Tests` already reached instead of re-running it, - # and `verdict` says whether that verdict covers the commit checked out - # below (#1643). `.event == 'push'` and `.head_branch == 'main'` + # and `head`'s `verified` says whether that verdict covers the commit + # checked out below (#1643). `.event == 'push'` and `.head_branch == 'main'` # both matter -- `workflow_run` fires for *every* completion of that # workflow, including one triggered by a pull request, and without this # check a green PR would publish docs from that PR's tree. if: >- - !cancelled() && needs.unit-tests.result != 'failure' && + !cancelled() && needs.head.result != 'failure' && (github.event_name != 'workflow_run' || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && - needs.verdict.outputs.verified == 'true' + needs.head.outputs.verified == 'true' )) concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. runs-on: macos-latest @@ -131,7 +97,7 @@ jobs: # relying on CodeQL tracing through the guard. `main`'s tip is also # the *right* commit for docs, which should reflect current main, # not a slightly older one -- so this is not a compromise here. - # Whether this commit is one that verdict covers is `verdict`'s + # Whether this commit is one that verdict covers is `head`'s # question (#1643). persist-credentials: false @@ -189,8 +155,8 @@ jobs: # top-level `contents: read`. This job runs no project code, and never on a # pull request. It needs `contents: write` to push `gh-pages`, and `actions: # read` to download the site `deploy-pages` uploaded in this run. That job's - # result is checked explicitly, under `!cancelled()`: `unit-tests` or `verdict` - # always skips, and a skip anywhere in the chain skips a job left with the + # result is checked explicitly, under `!cancelled()`: a job of `head` skips on + # every path, and a skip anywhere in the chain skips a job left with the # implicit `success()` (#1632). Its own group keeps one push to `gh-pages` at # a time, and its check below keeps an older build from replacing a newer one. publish-pages: diff --git a/.github/workflows/project-status.yml b/.github/workflows/project-status.yml index 5eacff56cb..f0bd9da271 100644 --- a/.github/workflows/project-status.yml +++ b/.github/workflows/project-status.yml @@ -54,8 +54,8 @@ on: # its item's concurrency queue below was full, a GitHub outage, or a label # changed while the secret was absent. 00:37 UTC sits well clear of the # Saturday jobs (CodeQL and GitHub Pages at 02:00, Python Compatibility at - # 04:00, Lint at 06:00, Vendor and Conda Update at 10:00), and off the hour - # mark that GitHub's scheduler queues most heavily. + # 04:00, Lint at 06:00, Vendor Update at 10:00, Conda Update at 18:00 since + # #1967), and off the hour mark that GitHub's scheduler queues most heavily. - cron: '37 0 * * *' workflow_dispatch: diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 73825e33d6..d71434a5cb 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -910,8 +910,8 @@ lists only ``push`` on ``v*`` tags and a ``workflow_run``. The chain has an extra hop: a push to ``main`` triggers **Unit Tests** (``unit-tests.yml``), whose completion triggers **Vendor Update** (``cron-vendor.yml``) via ``workflow_run`` -- ``cron-vendor.yml`` carries no -``push`` trigger of its own, deliberately, per its own comment at -``cron-vendor.yml:6-13`` -- and *that* completion triggers **Create Release** +``push`` trigger of its own, deliberately, per the comment on its +``workflow_run`` trigger -- and *that* completion triggers **Create Release** itself. :doc:`workflows` draws the repository-wide version of this graph; here is just the path that matters for a release: diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index c4dad486d3..fd5db0d3f6 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -389,12 +389,12 @@ itself call a status-check function -- effectively ``success()`` over ``needs:`` -- would still skip it, because ``github`` did not *succeed* in this run, it *skipped*. -``tag``, ``pypi`` and ``conda`` all guard against this the same way -``unit-tests``'s other callers already do for the identical problem -- +``tag``, ``pypi`` and ``conda`` all guard against this the same way the +other workflows gated on a test run already do for the identical problem -- ``cron-vendor.yml``, ``deploy-pages.yml`` and ``cron-conda.yml`` each open -with ``!cancelled() && needs.unit-tests.result != 'failure'`` so that a -skipped gate does not cascade-skip them, while an outright failure or a -genuine run cancellation still does. ``tag``, ``pypi`` and ``conda`` are +with ``!cancelled() && needs.head.result != 'failure'``, ``head`` being their +call to ``cron-head.yml``, so that a skipped gate does not cascade-skip them, +while an outright failure or a genuine run cancellation still does. ``tag``, ``pypi`` and ``conda`` are stricter about their *publishing* predecessors -- ``github``, and for ``conda`` also ``tag``: ``!cancelled() && (needs..result == 'success' || needs..result == 'skipped')``, not ``!= 'failure'``. The two spellings diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 2cabffcb12..856d20ec8d 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -69,7 +69,7 @@ At a Glance - :file:`cron-conda.yml` - -- - -- - - Sat 10:00 + - Sat 18:00 - **Unit Tests** - -- * - GitHub Pages @@ -79,6 +79,13 @@ At a Glance - Sat 02:00 - **Unit Tests** - -- + * - Cron Head + - :file:`cron-head.yml` + - -- + - -- + - -- + - -- + - ``workflow_call`` (reusable) * - Create Release - :file:`create-release.yml` - tags ``v*`` @@ -179,7 +186,7 @@ than starting a new one. PR --> GP SAT -->|02:00| GP - SAT -->|10:00| CU + SAT -->|18:00| CU PR --> LT SAT -->|06:00| LT @@ -210,6 +217,7 @@ than starting a new one. VU["Vendor Update
cron-vendor.yml"] CU["Conda Update
cron-conda.yml"] GP["GitHub Pages
deploy-pages.yml"] + CH["Cron Head
cron-head.yml"] CC["Coverage Comment
coverage-comment.yml"] CR["Create Release
create-release.yml"] @@ -219,23 +227,25 @@ than starting a new one. VU ==>|workflow_run: completed| CR UT ==>|workflow_run: completed| CC - VU -.-> UT - CU -.-> UT - GP -.-> UT + VU -.-> CH + CU -.-> CH + GP -.-> CH + CH -.-> UT CR -.-> UT The double relationship between **Unit Tests** and the dependants that also call it is the part that reading a single file cannot show, and it is deliberate rather than redundant. Vendor Update, Conda Update and GitHub Pages -call ``unit-tests.yml`` with ``gate-only: true`` themselves (dotted, above) -**only when their own trigger is** ``schedule`` -- their gate jobs' ``if:`` -skips ``workflow_run``, and GitHub Pages' skips ``pull_request`` as well -- so -only on the Saturday path, where no completed Unit Tests run exists yet to read -a verdict from. On the ``workflow_run`` path (thick arrows), each reads the -verdict Unit Tests already reached instead of re-running the gate a second -time, and acts on it only once its ``verdict`` job, described under the edge -list below, says it is a verdict on the commit it builds. Create Release has -no ``schedule``, and its gate job's ``if:`` reads ``version_check``'s evidence +each call **Cron Head** as their ``head`` job (dotted, above), and its test gate +calls ``unit-tests.yml`` with ``gate-only: true`` **only when the caller's +trigger is** ``schedule`` -- the gate's ``if:`` skips ``workflow_run`` and +``pull_request`` -- so only on the Saturday path, where no completed Unit Tests +run exists yet to read a verdict from. On the ``workflow_run`` path (thick +arrows), each reads the verdict Unit Tests already reached instead of re-running +the gate a second time, and acts on it only once the head's ``verdict`` job, +described under the edge list below, says it is a verdict on the commit it +builds (`#1967 `__). Create +Release has no ``schedule``, and its gate job's ``if:`` reads ``version_check``'s evidence rather than the event, so its gate can run whichever way the run was raised, once ``version_check`` itself has passed: on a ``v*`` tag push, and on a ``workflow_run`` from Vendor Update when some target is not yet published @@ -247,12 +257,12 @@ rather than the event, so its gate can run whichever way the run was raised, onc Each edge cites the ``on:`` block it comes from, so grepping those blocks re-derives the list: -* ``.github/workflows/cron-vendor.yml:14-16`` -- **Vendor Update** fires on - completion of **Unit Tests**. -* ``.github/workflows/cron-conda.yml:17-19`` -- **Conda Update** fires on - completion of **Unit Tests**. -* ``.github/workflows/deploy-pages.yml:12-14`` -- **GitHub Pages** fires on - completion of **Unit Tests**. +* ``.github/workflows/cron-vendor.yml``, its ``workflow_run`` trigger -- + **Vendor Update** fires on completion of **Unit Tests**. +* ``.github/workflows/cron-conda.yml``, its ``workflow_run`` trigger -- + **Conda Update** fires on completion of **Unit Tests**. +* ``.github/workflows/deploy-pages.yml``, its ``workflow_run`` trigger -- + **GitHub Pages** fires on completion of **Unit Tests**. * ``.github/workflows/create-release.yml:6-9`` -- **Create Release** fires on completion of **Vendor Update**. * ``.github/workflows/coverage-comment.yml:21-23`` -- **Coverage Comment** fires @@ -271,8 +281,9 @@ checks out ``github.sha``, ``main``'s tip when the event is raised, not ``github.event.workflow_run.head_sha``, the commit the completed run tested, and in a merge wave the two differ: Conda Update 38325136710 bumped 2e941bbb7 under the verdict for 9083163f5 (`#1643 `__). -So Vendor Update, Conda Update and GitHub Pages each run a ``verdict`` job first, -and act only when the tip is the verified commit, or that commit plus bot commits +So Vendor Update, Conda Update and GitHub Pages each run Cron Head's ``verdict`` +job first, in their ``head`` job, and act only when its ``verified`` output says +the tip is the verified commit, or that commit plus bot commits to ``conda/build`` and ``conda/requirements.txt`` alone; otherwise they skip, and the tip's own Unit Tests run raises them again. Vendor Update's run still concludes ``success`` then, since ``verdict`` ran, so Create Release reads its @@ -301,17 +312,53 @@ Reusable-Workflow Calls (``uses:``) A ``uses:`` call is a different relationship from triggering: it runs inside the caller's own workflow run, as one of the caller's own jobs, rather than -starting an independent run after the caller finishes. Every call in this -repository targets the same reusable workflow, with the same input: +starting an independent run after the caller finishes. The calls target +``unit-tests.yml``, always with ``gate-only: true``, and ``cron-head.yml``: * ``.github/workflows/create-release.yml:595`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. -* ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls - ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. -* ``.github/workflows/cron-vendor.yml:56`` -- job ``unit-tests`` calls - ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. -* ``.github/workflows/deploy-pages.yml:55`` -- job ``unit-tests`` calls +* ``.github/workflows/cron-head.yml``, job ``unit-tests`` -- calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. +* ``.github/workflows/cron-conda.yml``, ``cron-vendor.yml`` and + ``deploy-pages.yml``, job ``head`` in each -- calls + ``./.github/workflows/cron-head.yml``, with no inputs. The call's ``verified`` + output is its ``verdict`` job's, and the acting job reads it beside + ``needs.head.result``. + +Cron Head (`#1967 `__) is +one copy of the test gate and the ``verdict`` job, which Conda Update, Vendor +Update and GitHub Pages used to carry each. It holds ``contents: read`` and no +more, which is what each caller grants its ``head`` job and what +``unit-tests.yml`` is called with. Every expression in it reads the caller's ``github`` context, so its jobs decide on +the event that started the caller and check out the caller's ``github.sha``. A +job of the call skips on every path -- the gate everywhere but the schedule, +``verdict`` everywhere but a ``workflow_run`` -- so each acting job keeps +``!cancelled()``, for the reason the skip-cascade section below gives, and +treats a ``head`` result other than ``failure`` as passing. + +Their schedules are eight hours apart on Saturdays: GitHub Pages at 02:00, +Vendor Update at 10:00 and Conda Update at 18:00 UTC +(`#1967 `__). The owner's +ruling kept a schedule on each and allowed them aligned or spread, and nothing +orders them: + +* None reads another's output. A Vendor Update push reaches Conda Update and + GitHub Pages anyway, as a Unit Tests run whose completion raises both on the + ``workflow_run`` path. Create Release reads only the Vendor Update run that + triggered it, through its ``vendor_update`` job, so the time of the others + does not matter to it. +* At one time they cost more. Vendor Update and Conda Update both ran at 10:00 + until this change, and each scheduled pair read back from 2026-09-26 on + checked out the same commit -- on 2026-10-17, Conda Update 39023770904 and + Vendor Update 39023891165 both gated 44ed96fc3, their ``Gate (full suite, + …)`` jobs taking twelve and ten minutes. Their writing jobs share the ``repository-maintenance`` group, so ``conda-update`` + then waited for ``vendor-update`` to finish, and a third job joining that + group cancels the one pending there. + +Spread, each ``head`` gates alone, and Conda Update runs once Vendor Update's +Create Release has had the day to publish, so its ``Check the GitHub release +exists`` step finds the release rather than skipping the GitHub upload +(`#1634 `__). ``gate-only: true`` selects not only ``unit-tests.yml``'s ``gate`` (the full suite, on ``latest``) but also ``changelog`` (checks ``CHANGELOG.md`` against its diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index dfec736ef6..0af90b7dbe 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -207,6 +207,12 @@ rule, and Conda Tag tags the ``CONDA_COMMIT`` it pushed; :class:`TestCondaTagTagsWhatWasPushed` runs Conda Tag's own steps after it. +Since #1967 the ``unit-tests`` gate and the ``verdict`` job are the jobs of +:file:`cron-head.yml`, which ``cron-conda.yml`` calls as its ``head`` job. The +rows still say whether each of those two runs, and :data:`HEAD_JOBS` are +decided against that file; the jobs of ``cron-conda.yml`` read ``head``'s result +and its ``verified`` output. + """ from __future__ import annotations @@ -1360,6 +1366,8 @@ def text_gate_violations(condition: 'str', job: 'str') -> 'list[str]': #: else -- every expectation above this point is about ``create-release.yml``'s #: own job graph. CONDA_WORKFLOW = ROOT / '.github' / 'workflows' / 'cron-conda.yml' +#: :file:`cron-head.yml`, which ``cron-conda.yml``'s ``head`` job calls (#1967). +HEAD_WORKFLOW = ROOT / '.github' / 'workflows' / 'cron-head.yml' #: A call to one of Actions' four status-check functions. Their presence #: anywhere in a job's ``if:`` is what replaces the implicit ``success()`` @@ -1617,7 +1625,30 @@ def job_runs(jobs: 'dict[str, str]', name: 'str', context: 'dict[str, object]') #: The jobs :file:`cron-conda.yml` declares. Named so that a scenario row can #: be keyed on them and ``test_the_scan_found_the_jobs`` fails loudly when the #: graph is renamed or extended, rather than leaving a new job unchecked. -CONDA_JOBS = ('unit-tests', 'verdict', 'conda-update', 'conda-tag', 'conda-dist') +#: ``head`` is the call to :data:`HEAD_WORKFLOW`, and has no ``if:`` of its own. +CONDA_JOBS = ('head', 'conda-update', 'conda-tag', 'conda-dist') +#: The jobs of :data:`HEAD_WORKFLOW`, which a row can be keyed on as well (#1967). +HEAD_JOBS = ('unit-tests', 'verdict') +#: The jobs whose own ``if:`` a row decides. +CONDA_DECIDED = HEAD_JOBS + CONDA_JOBS[1:] + + +def head_result(unit_tests: 'str', verdict: 'str') -> 'str': + """``needs.head.result`` as a row takes it, from the results of :data:`HEAD_JOBS`. + + A failure in either is a failure, which is what ``conda-update``'s ``!= + 'failure'`` reads. How Actions reports a call whose jobs partly skipped is + not pinned by any run here, so one where a job ran is ``success`` and one + where none did is ``skipped``; ``conda-update`` accepts both, and + ``test_removing_the_status_check_reproduces_the_defect`` passes ``head`` + as ``skipped`` to keep the #1632 shape. + + """ + results = (unit_tests, verdict) + for result in ('failure', 'cancelled', 'success'): + if result in results: + return result + return 'skipped' #: What run 38325136710's Push Changes pushed, "Bumped build to 3". PUSHED_COMMIT = 'f4f94bcad4a195f98f1bd07d4d0bc2b6be7c9d15' @@ -1626,6 +1657,7 @@ def job_runs(jobs: 'dict[str, str]', name: 'str', context: 'dict[str, object]') def conda_context(unit_tests: 'str' = 'skipped', verdict: 'str' = 'success', verified: 'object' = 'true', + head: 'Optional[str]' = None, conda_update: 'str' = 'success', conda_tag: 'str' = 'success', conda_dist: 'str' = 'success', @@ -1654,6 +1686,10 @@ def conda_context(unit_tests: 'str' = 'skipped', checked out -- and since #1643 that run's own shape is the row that skips ``conda-update``. ``verified=NULL`` is a ``verdict`` that wrote no output. + ``unit_tests`` and ``verdict`` are :data:`HEAD_JOBS`' results (#1967). They + sit under ``needs.head.jobs``, which no expression reads, and make + ``needs.head.result`` through :func:`head_result` unless ``head`` names it. + ``conda-update`` is given as its *steps'* outputs, which :func:`resolved_context` maps through the job's declared ``outputs:``: ``files_changed`` for Verify Changed files, ``pushed`` for Push Changes. @@ -1698,9 +1734,11 @@ def conda_context(unit_tests: 'str' = 'skipped', return { 'needs': { - 'unit-tests': {'result': unit_tests}, - 'verdict': {'result': verdict, - 'outputs': {} if verified is NULL else {'verified': verified}}, + 'head': { + 'result': head_result(unit_tests, verdict) if head is None else head, + 'outputs': {} if verified is NULL else {'verified': verified}, + 'jobs': {'unit-tests': {'result': unit_tests}, 'verdict': {'result': verdict}}, + }, 'conda-update': {'result': conda_update, 'steps': steps}, 'conda-tag': {'result': conda_tag}, 'conda-dist': {'result': conda_dist}, @@ -3698,18 +3736,29 @@ def test_the_same_job_to_if_mapping(self) -> None: class CondaWorkflowMixin: - """Reads and scans :file:`cron-conda.yml` once per test.""" + """Reads and scans :file:`cron-conda.yml`, and the head it calls, once per test.""" @classmethod def setUpClass(cls) -> None: cls.text = CONDA_WORKFLOW.read_text(encoding='utf-8') # type: ignore[attr-defined] cls.jobs = job_blocks(cls.text, CONDA_WORKFLOW) # type: ignore[attr-defined] + cls.head_jobs = job_blocks(HEAD_WORKFLOW.read_text(encoding='utf-8'), # type: ignore[attr-defined] + HEAD_WORKFLOW) + + def decides(self, job: 'str', context: 'dict[str, object]') -> 'bool': + """:func:`job_runs` for ``job``, in :data:`HEAD_WORKFLOW` for :data:`HEAD_JOBS`, + which see the caller's ``github`` and no ``needs`` of their own. + + """ + if job in HEAD_JOBS: + return job_runs(self.head_jobs, job, {**context, 'needs': {}}) # type: ignore[attr-defined] + return job_runs(self.jobs, job, context) # type: ignore[attr-defined] class TestCondaBumpIsTaggedAndUploaded(CondaWorkflowMixin, unittest.TestCase): """#1632: a committed build bump has to reach the tag and the upload. - The chain is ``unit-tests`` (the gate) -> ``conda-update`` (bumps + The chain is ``head`` (the gate, :file:`cron-head.yml`) -> ``conda-update`` (bumps ``conda/build`` and pushes the commit) -> ``conda-tag`` (pushes ``conda-+``) -> ``conda-dist`` (builds that tag and uploads it to anaconda.org). Since #731 the gate skips on the ``workflow_run`` @@ -3763,7 +3812,7 @@ def test_the_table_covers_every_job_and_both_polarities(self) -> None: table of runs by a job with no gate at all. """ - for job in CONDA_JOBS: + for job in CONDA_DECIDED: outcomes = {expectations[job] for _, _, expectations in CONDA_ROWS if job in expectations} with self.subTest(job=job): @@ -3786,7 +3835,7 @@ def test_each_row_is_internally_consistent(self) -> None: needs = context['needs'] assert isinstance(needs, dict) for job, expected in expectations.items(): - entry = needs[job] + entry = needs['head']['jobs'][job] if job in HEAD_JOBS else needs[job] assert isinstance(entry, dict) with self.subTest(row=name, job=job): if expected: @@ -3807,7 +3856,7 @@ def test_every_job_decides_correctly_on_every_row(self) -> None: violations = [] # type: list[str] for name, context, expectations in CONDA_ROWS: for job, expected in expectations.items(): - if job_runs(self.jobs, job, context) != expected: # type: ignore[arg-type] + if self.decides(job, context) != expected: violations.append( f'{name}: `{job}` should ' f'{"run" if expected else "skip"}, it did not' @@ -3848,7 +3897,10 @@ def test_removing_the_status_check_reproduces_the_defect(self) -> None: """ pre_fix = self._pre_fix_jobs() - context = conda_context() + # #1967: the gate is a job of `head` now, and a row reports a call one + # of whose jobs ran as `success` (see `head_result`). `skipped` is the + # shape #1632 found, a skipped job ahead of the chain. + context = conda_context(head='skipped') for job in ('conda-tag', 'conda-dist'): with self.subTest(job=job): self.assertTrue( @@ -3943,7 +3995,7 @@ def test_the_publishing_jobs_name_their_predecessors_strictly(self) -> None: calls_status_check(condition), f'`{job}`\'s `if:` ({condition!r}) calls no status-check ' f'function, so Actions conjoins the implicit `success()` and ' - f'the skipped `unit-tests` gate cascade-skips it -- #1632', + f'a skip in `head` cascade-skips it -- #1632', ) self.assertIn( '!cancelled()', condition, diff --git a/tests/project/test_workflow_hardening_1968.py b/tests/project/test_workflow_hardening_1968.py index 9bc4a74cf9..c0f7532ec7 100644 --- a/tests/project/test_workflow_hardening_1968.py +++ b/tests/project/test_workflow_hardening_1968.py @@ -234,21 +234,22 @@ def test_a_pull_request_builds_the_docs_with_read_only_contents(self) -> None: self.assertEqual(publish['permissions'], {'actions': 'read', 'contents': 'write'}) self.assertEqual(publish['needs'], ['deploy-pages']) - # `(event, unit-tests, verdict, deploy-pages, whether publish-pages runs)`. - # One of the first two always skips, and a skip anywhere in the chain - # skips a job left with the implicit `success()` (#1632). + # `(event, head, deploy-pages, whether publish-pages runs)`. A job of + # `head` skips on every path (#1967), and a skip anywhere in the chain + # skips a job left with the implicit `success()` (#1632), so `head` is + # given both ways. rows = ( - ('workflow_run', 'skipped', 'success', 'success', True), - ('schedule', 'success', 'skipped', 'success', True), - ('pull_request', 'skipped', 'skipped', 'success', False), - ('workflow_run', 'skipped', 'success', 'skipped', False), - ('workflow_run', 'skipped', 'success', 'failure', False), - ('schedule', 'success', 'skipped', 'cancelled', False), + ('workflow_run', 'success', 'success', True), + ('workflow_run', 'skipped', 'success', True), + ('schedule', 'success', 'success', True), + ('pull_request', 'skipped', 'success', False), + ('workflow_run', 'success', 'skipped', False), + ('workflow_run', 'success', 'failure', False), + ('schedule', 'success', 'cancelled', False), ) - for event, gate, verdict, built, expected in rows: + for event, head, built, expected in rows: context = {STATUS: {'cancelled': False}, 'github': {'event_name': event, 'event': {}}, 'needs': { - 'unit-tests': {'result': gate}, 'verdict': {'result': verdict}, - 'deploy-pages': {'result': built}, + 'head': {'result': head}, 'deploy-pages': {'result': built}, }} # type: dict[str, object] with self.subTest(event=event, built=built): self.assertEqual(job_runs(jobs, 'publish-pages', context), expected) diff --git a/tests/project/test_workflow_run_verdict.py b/tests/project/test_workflow_run_verdict.py index 67df9bd4cf..82fe2d3222 100644 --- a/tests/project/test_workflow_run_verdict.py +++ b/tests/project/test_workflow_run_verdict.py @@ -16,7 +16,9 @@ and paths are git's answers rather than a model's; * :class:`TestEachWorkflowGatesOnTheVerdict` evaluates each workflow's job conditions and concurrency group over the scenarios, with the evaluator - :mod:`tests.project.test_release_gates` pins. + :mod:`tests.project.test_release_gates` pins. Since #1967 the test gate and + the ``verdict`` job are one copy, in :data:`HEAD`, which each workflow calls + as its ``head`` job. """ @@ -44,7 +46,9 @@ SCRIPT = ROOT / 'util' / 'verdict_covers_checkout.py' WORKFLOWS = ROOT / '.github' / 'workflows' -#: ``{workflow file: the job the verdict gates}``. +#: The reusable workflow holding the test gate and the ``verdict`` job (#1967). +HEAD = 'cron-head.yml' +#: ``{workflow file: the job the verdict gates}``, each of which calls :data:`HEAD`. ACTING_JOBS = { 'cron-conda.yml': 'conda-update', 'cron-vendor.yml': 'vendor-update', @@ -340,14 +344,35 @@ def test_the_harness_git_runs_no_maintenance(self) -> None: self.assertEqual(written, [], 'a commit ran maintenance in the test repository') +def head_result(unit_tests: 'str', verdict: 'str') -> 'str': + """What the caller's ``needs.head.result`` is taken to be, from the two jobs of + :data:`HEAD` (#1967). + + A failure in either is a failure, which is what the acting jobs' ``!= + 'failure'`` reads. How Actions reports a call whose jobs partly skipped is + not pinned by any run here, so a skip-and-success call is modelled as + ``success`` and an all-skipped one as ``skipped``; the acting jobs accept + both, which ``test_a_skipped_head_cannot_cascade_into_the_acting_job`` + checks for the second. + + """ + results = (unit_tests, verdict) + for result in ('failure', 'cancelled', 'success'): + if result in results: + return result + return 'skipped' + + def _context(event_name: 'str' = 'workflow_run', conclusion: 'str' = 'success', event: 'str' = 'push', head_branch: 'str' = 'main', head_sha: 'str' = 'f' * 40, unit_tests: 'str' = 'skipped', verdict: 'str' = 'success', verified: 'object' = 'true', cancelled: bool = False) -> 'dict[str, object]': """One run's ``github``/``needs`` context; the defaults are a verdict that covers its checkout. + ``unit_tests`` and ``verdict`` are the results of :data:`HEAD`'s two jobs, + which make the caller's ``head`` result through :func:`head_result`. ``verified=NULL`` is a ``verdict`` job that wrote no output, which is what a - skipped or failed one leaves. + skipped or failed one leaves, and the call passes it on as it is. """ outputs = {} if verified is NULL else {'verified': verified} @@ -361,99 +386,175 @@ def _context(event_name: 'str' = 'workflow_run', conclusion: 'str' = 'success', 'head_branch': head_branch, 'head_sha': head_sha}} return { 'github': github, - 'needs': {'unit-tests': {'result': unit_tests}, - 'verdict': {'result': verdict, 'outputs': outputs}}, + 'needs': {'head': {'result': head_result(unit_tests, verdict), 'outputs': outputs}}, STATUS: {'cancelled': cancelled}, } -#: ``[(row, context, whether verdict's if: holds, whether the acting job's does)]``, -#: for every workflow above; :data:`None` asserts nothing about that job. +def _head_context(context: 'dict[str, object]') -> 'dict[str, object]': + """``context`` as :data:`HEAD`'s own jobs see it: the caller's ``github``, no ``needs``.""" + return {**context, 'needs': {}} + + +#: ``[(row, context, whether the test gate's if: holds, whether verdict's does, +#: whether the acting job's does)]``, for every workflow above; :data:`None` +#: asserts nothing about that job. The first two are :data:`HEAD`'s jobs. ROWS = [ ('run 38325136710: the verdict is for an older commit', - _context(verified='false'), True, False), + _context(verified='false'), False, True, False), ("run 38326990571: the verdict covers the checkout", - _context(), True, True), + _context(), False, True, True), ('verdict failed before writing its answer', - _context(verdict='failure', verified=NULL), True, False), + _context(verdict='failure', verified=NULL), False, True, False), ('the Saturday schedule, the gate green', - _context(event_name='schedule', unit_tests='success', verdict='skipped', verified=NULL), False, True), + _context(event_name='schedule', unit_tests='success', verdict='skipped', verified=NULL), + True, False, True), ('the Saturday schedule, the gate failed', - _context(event_name='schedule', unit_tests='failure', verdict='skipped', verified=NULL), False, False), + _context(event_name='schedule', unit_tests='failure', verdict='skipped', verified=NULL), + True, False, False), ("a pull request's Unit Tests completed", - _context(event='pull_request', verdict='skipped', verified=NULL), False, False), + _context(event='pull_request', verdict='skipped', verified=NULL), False, False, False), ('Unit Tests failed on main', - _context(conclusion='failure', verdict='skipped', verified=NULL), False, False), + _context(conclusion='failure', verdict='skipped', verified=NULL), False, False, False), ('Unit Tests completed on a branch that is not main', - _context(head_branch='dependabot/pip/scapy-2.6.2', verdict='skipped', verified=NULL), False, False), + _context(head_branch='dependabot/pip/scapy-2.6.2', verdict='skipped', verified=NULL), + False, False, False), ('the run was cancelled', - _context(cancelled=True), None, False), + _context(cancelled=True), None, None, False), ] #: GitHub Pages also builds, without deploying, for a pull request against ``main``. PAGES_ROWS = [ ('a pull request against main', - _context(event_name='pull_request', verdict='skipped', verified=NULL), False, True), + _context(event_name='pull_request', verdict='skipped', verified=NULL), False, False, True), ] _GROUP = re.compile(r'^concurrency:\n group: (?P[^$\n]*)\$\{\{(?P[^}]*)\}\}\n', re.M) +#: The line each caller's ``head`` job calls :data:`HEAD` with. +_CALL = f'uses: ./.github/workflows/{HEAD}' class TestEachWorkflowGatesOnTheVerdict(unittest.TestCase): - """The ``verdict`` job, and the job it gates, in each :data:`ACTING_JOBS` workflow.""" + """:data:`HEAD`'s jobs, and the job they gate, in each :data:`ACTING_JOBS` workflow.""" @classmethod def setUpClass(cls) -> None: - cls.texts = {name: (WORKFLOWS / name).read_text(encoding='utf-8') for name in ACTING_JOBS} + cls.texts = {name: (WORKFLOWS / name).read_text(encoding='utf-8') + for name in (*ACTING_JOBS, HEAD)} cls.jobs = {name: job_blocks(text) for name, text in cls.texts.items()} def _jobs(self, workflow: 'str') -> 'dict[str, str]': jobs = self.jobs[workflow] - if 'verdict' not in jobs: - self.fail(f'{workflow} has no `verdict` job, so it acts on whatever commit it checks out') + if 'head' not in jobs or _CALL not in jobs['head']: + self.fail(f'{workflow} has no `head` job calling {HEAD}, so it acts on whatever ' + f'commit it checks out') return jobs - def test_each_acting_job_needs_the_verdict(self) -> None: + def test_each_acting_job_needs_the_head(self) -> None: for workflow, job in ACTING_JOBS.items(): with self.subTest(workflow=workflow): - self.assertIn('verdict', declared_needs(self._jobs(workflow)[job])) + self.assertEqual(declared_needs(self._jobs(workflow)[job]), ['head']) def test_every_scenario_decides_correctly(self) -> None: - """The table above, for both jobs, in every workflow.""" + """The table above, for :data:`HEAD`'s jobs and each acting job.""" + head = self.jobs[HEAD] violations = [] # type: list[str] for workflow, job in ACTING_JOBS.items(): - jobs = self.jobs[workflow] + jobs = self._jobs(workflow) rows = ROWS + (PAGES_ROWS if workflow == 'deploy-pages.yml' else []) - for name, context, verdict_runs, acting_runs in rows: - for target, expected in (('verdict', verdict_runs), (job, acting_runs)): + for name, context, gate_runs, verdict_runs, acting_runs in rows: + for target, block, scope, expected in ( + ('unit-tests', head.get('unit-tests'), _head_context(context), gate_runs), + ('verdict', head.get('verdict'), _head_context(context), verdict_runs), + (job, jobs.get(job), context, acting_runs)): if expected is None: continue - if target not in jobs: + if block is None: violations.append(f'{workflow}: {name}: no `{target}` job') continue - if evaluate_condition(declared_if(jobs[target]), context) != expected: + if evaluate_condition(declared_if(block), scope) != expected: violations.append(f'{workflow}: {name}: `{target}` should ' f'{"run" if expected else "skip"}, it does not') if violations: self.fail(f'{len(violations)} scenario(s) decided wrongly:\n' + '\n'.join(violations)) - def test_a_skipped_verdict_cannot_cascade_into_the_acting_job(self) -> None: - """``verdict`` skips on the schedule, so without a status-check function - the implicit ``success()`` would skip the job behind it there too. + def test_a_skipped_head_cannot_cascade_into_the_acting_job(self) -> None: + """A job of ``head`` skips on every path, so without a status-check + function the implicit ``success()`` could skip the job behind it. """ + skipped = _context(event_name='schedule', unit_tests='skipped', verdict='skipped', verified=NULL) for workflow, job in ACTING_JOBS.items(): with self.subTest(workflow=workflow): - self.assertTrue(declared_if(self._jobs(workflow)[job]).startswith('!cancelled() && ')) + condition = declared_if(self._jobs(workflow)[job]) + self.assertTrue(condition.startswith('!cancelled() && ')) + self.assertTrue(evaluate_condition(condition, skipped)) + + def test_the_three_call_one_head(self) -> None: + """#1967: one copy of the gate and the verdict, called the same way from each. + + The ``head`` jobs differ in their ``name:`` alone, no acting workflow + keeps a gate or a ``verdict`` of its own, and nothing else calls + :data:`HEAD`. + + """ + blocks = {} # type: dict[str, str] + for workflow in ACTING_JOBS: + jobs = self._jobs(workflow) + with self.subTest(workflow=workflow): + self.assertNotIn('verdict', jobs) + self.assertNotIn('uses: ./.github/workflows/unit-tests.yml', self.texts[workflow]) + self.assertNotIn('run: python3 util/verdict_covers_checkout.py\n', self.texts[workflow]) + blocks[workflow] = re.sub(r'(?m)^ name: .*\n', '', jobs['head'] + '\n') + self.assertEqual(len(set(blocks.values())), 1, blocks) + callers = sorted(path.name for path in WORKFLOWS.glob('*.yml') + if _CALL in path.read_text(encoding='utf-8')) + self.assertEqual(callers, sorted(ACTING_JOBS)) + + def test_the_schedules_are_spread_with_vendor_before_conda(self) -> None: + """#1967: one Saturday schedule each, at distinct hours, so no two heads + gate at once; Conda Update after Vendor Update, so its GitHub upload + finds the release Vendor Update's Create Release made (#1634). - def test_the_verdict_job_is_one_job_in_three_places(self) -> None: - """Identical text, so a fix to one cannot leave the others behind.""" - blocks = {workflow: self._jobs(workflow)['verdict'] for workflow in ACTING_JOBS} - self.assertEqual(len(set(blocks.values())), 1, sorted(blocks)) + """ + hours = {} # type: dict[str, int] + for workflow in ACTING_JOBS: + crons = re.findall(r"(?m)^ - cron: '(\d+) (\d+) \* \* (\d)'", self.texts[workflow]) + with self.subTest(workflow=workflow): + self.assertEqual(len(crons), 1, crons) + minute, hour, day = crons[0] + self.assertEqual((minute, day), ('0', '6')) + hours[workflow] = int(hour) + self.assertEqual(len(set(hours.values())), len(hours), hours) + self.assertLess(hours['cron-vendor.yml'], hours['cron-conda.yml']) + + def test_the_head_is_reusable_only_and_holds_what_its_callers_grant(self) -> None: + """``workflow_call`` alone, ``verified`` passed out from ``verdict``, and + ``contents: read`` everywhere: what each caller grants ``head``, and what + ``unit-tests.yml`` is called with elsewhere. + + """ + text = self.texts[HEAD] + triggers = re.search(r'(?ms)^on:\n(.*?)^\S', text) + self.assertIsNotNone(triggers) + self.assertEqual(re.findall(r'(?m)^ ([\w-]+):', triggers.group(1)), # type: ignore[union-attr] + ['workflow_call']) + self.assertIn('value: ${{ jobs.verdict.outputs.verified }}', text) + self.assertIn('\npermissions:\n contents: read\n', text) + scopes = {line.strip() for line in text.splitlines() + if re.match(r'^ +(actions|attestations|checks|contents|deployments|discussions|' + r'id-token|issues|models|packages|pages|pull-requests|' + r'security-events|statuses): ', line)} + self.assertEqual(scopes, {'contents: read'}) + self.assertIn(' permissions:\n contents: read\n uses: ./.github/workflows/unit-tests.yml\n' + ' with:\n gate-only: true', self.jobs[HEAD]['unit-tests']) + for workflow in ACTING_JOBS: + with self.subTest(workflow=workflow): + self.assertIn(' permissions:\n contents: read\n', self._jobs(workflow)['head']) def test_the_verdict_job_reads_the_payload_as_data(self) -> None: """The sha arrives through ``env:``, and the script it runs exists.""" - block = self._jobs('cron-conda.yml')['verdict'] + block = self.jobs[HEAD]['verdict'] self.assertIn('VERDICT_SHA: ${{ github.event.workflow_run.head_sha }}', block) command = re.search(r'(?m)^ +run: python3 (\S+)$', block) self.assertIsNotNone(command, block) @@ -461,13 +562,16 @@ def test_the_verdict_job_reads_the_payload_as_data(self) -> None: self.assertEqual(block.count('${{'), 2, 'the only expressions are the output and the env value') def test_the_verdict_job_checks_out_what_the_acting_job_builds(self) -> None: - """Neither checkout names a ``ref:``, so both are the default ``github.sha``.""" + """Neither checkout names a ``ref:``, so both are the default ``github.sha``, + which in a called workflow is the caller's. + + """ for workflow, job in ACTING_JOBS.items(): - jobs = self._jobs(workflow) - for target in ('verdict', job): + for target, block in (('verdict', self.jobs[HEAD]['verdict']), + (job, self._jobs(workflow)[job])): with self.subTest(workflow=workflow, job=target): - self.assertIn('uses: actions/checkout@', jobs[target]) - self.assertNotRegex(jobs[target], r'(?m)^ +ref:') + self.assertIn('uses: actions/checkout@', block) + self.assertNotRegex(block, r'(?m)^ +ref:') def test_the_verdict_checkout_reaches_past_a_bot_commit(self) -> None: """At ``fetch-depth: 1`` only ``HEAD`` is fetched, so with a bot commit on @@ -476,14 +580,12 @@ def test_the_verdict_checkout_reaches_past_a_bot_commit(self) -> None: ``0`` fetches all history. """ - for workflow in ACTING_JOBS: - with self.subTest(workflow=workflow): - depths = re.findall(r'(?m)^ +fetch-depth: *(\S+)$', self._jobs(workflow)['verdict']) - self.assertEqual(len(depths), 1, 'one checkout, with an explicit fetch-depth') - self.assertRegex(depths[0], r'^\d+$') - depth = int(depths[0]) - self.assertTrue(depth == 0 or depth >= 2, - f'fetch-depth {depth} cannot hold the verdict under a bot commit') + depths = re.findall(r'(?m)^ +fetch-depth: *(\S+)$', self.jobs[HEAD]['verdict']) + self.assertEqual(len(depths), 1, 'one checkout, with an explicit fetch-depth') + self.assertRegex(depths[0], r'^\d+$') + depth = int(depths[0]) + self.assertTrue(depth == 0 or depth >= 2, + f'fetch-depth {depth} cannot hold the verdict under a bot commit') def test_a_stale_run_cannot_share_a_concurrency_group_with_a_fresh_one(self) -> None: """GitHub cancels a pending run when a newer one joins its group, so a run @@ -515,13 +617,20 @@ def test_the_scanner_agrees_with_yaml(self) -> None: import yaml # pylint: disable=import-outside-toplevel except ImportError: self.skipTest('PyYAML is only installed with the test extra') - for workflow, job in ACTING_JOBS.items(): + targets = list(ACTING_JOBS.items()) + [(HEAD, 'unit-tests'), (HEAD, 'verdict')] + for workflow, target in targets: parsed = yaml.safe_load(self.texts[workflow])['jobs'] - for target in ('verdict', job): - with self.subTest(workflow=workflow, job=target): - self.assertIn(target, sorted(parsed)) - self.assertEqual(' '.join(parsed[target]['if'].split()), - ' '.join(declared_if(self._jobs(workflow)[target]).split())) + with self.subTest(workflow=workflow, job=target): + self.assertIn(target, sorted(parsed)) + self.assertEqual(' '.join(parsed[target]['if'].split()), + ' '.join(declared_if(self.jobs[workflow][target]).split())) + for workflow in ACTING_JOBS: + parsed = yaml.safe_load(self.texts[workflow])['jobs']['head'] + with self.subTest(workflow=workflow, job='head'): + self.assertEqual(parsed['uses'], f'./.github/workflows/{HEAD}') + self.assertEqual(parsed['permissions'], {'contents': 'read'}) + for key in ('if', 'with', 'secrets'): + self.assertNotIn(key, parsed) if __name__ == '__main__':