From d20454483d3751c0e3ded0de10424e670b6383a2 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Wed, 14 Oct 2026 20:10:03 -0400 Subject: [PATCH] ci(release): a push that releases nothing stays green when its version is tagged elsewhere (#1748) - version_check gains an `owner` step that resolves the v* tag as #1634's check does and outputs PCAPKIT_RELEASED_AT and PCAPKIT_RELEASED_ELSEWHERE. - Off a v* ref, a tag naming another commit skips the release test gate and github, tag, pypi and conda; a v* push still meets #1634's check. `!= 'true'` sends an unset output to the guards, as #1052 does. - release_status reports that case before reconciling: a notice when the release is complete, a warning naming both commits, what is missing and the remedy when not, and exits 0. The #888 stranded shape still exits 1. - A new vendor_update job reads the triggering Vendor Update's jobs; one that concluded success with vendor-update skipped (run 38650191283) no longer runs version_check, and release_status says why. - Tests: truth-table rows and mutations for the new clause, version_check's run through vendor_update's skip on a push, release_status scenarios, the owner step and vendor_update's script against real shapes. - Docs: releasing.rst and workflows.rst, line citations renumbered. Closes #1748 --- .github/workflows/create-release.yml | 165 ++++++++++- docs/source/contributing/releasing.rst | 64 ++-- docs/source/contributing/workflows.rst | 55 ++-- .../test_python_versions_update_1660.py | 8 +- .../project/test_release_concurrency_1705.py | 36 ++- tests/project/test_release_gates.py | 273 ++++++++++++++++-- ...st_release_reads_the_vendor_update_1748.py | 164 +++++++++++ .../test_release_tags_the_run_sha_1634.py | 94 ++++++ tests/project/test_unit_tests_queue.py | 8 +- 9 files changed, 788 insertions(+), 79 deletions(-) create mode 100644 tests/project/test_release_reads_the_vendor_update_1748.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 59cfdac79..2d37271c3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -189,8 +189,80 @@ concurrency: # ``version_check`` skips the tag-push run that tag starts. # ``tests/project/test_release_tags_the_run_sha_1634.py`` asserts all of this # and runs ``tag``'s scripts against a scratch repository. +# +# #1748: so a version tagged at another commit would fail every later run for +# it: ``v1.5.0b12`` names ``2025da5e5`` with 1 of its 10 Anaconda files, and +# each push-raised run spent the test gate and 10 ``conda`` legs on refusals. +# ``version_check``'s ``owner`` step resolves the ``v*`` tag as the #1634 check +# does, and on a ref that is not a ``v*`` tag, ``PCAPKIT_RELEASED_ELSEWHERE`` +# reading ``'true'`` skips the gate and all four publishers -- ``github`` +# mostly skips there already, the tag existing. ``release_status`` then posts +# a notice when that release is complete and a warning naming what it lacks +# when not, and stays green. A ``v*`` push still meets the #1634 check, and +# ``!= 'true'`` sends an unset output there too, as #1052's ``!= 'true'`` does. +# The ``vendor_update`` job below is the issue's other half. jobs: + # #1748: a Vendor Update raised by a Unit Tests verdict for another commit + # updates nothing -- its ``verdict`` job finds the verdict does not cover the + # tip it checked out, and ``vendor-update`` skips -- yet the run concludes + # ``success``, and Create Release took it for a real one (Vendor Update + # 38650191283, a second Create Release run for ``0cdd3cdd1``). Measured: that + # run's jobs read ``success``/``skipped``/``skipped``, and 38655782606, whose + # three jobs all skipped, concluded ``skipped``; ``verdict`` has to run to + # decide, so Vendor Update could not conclude otherwise without a failed or + # cancelled run. This job reads that run's ``vendor-update`` job instead, with + # "List jobs for a workflow run", whose ``filter`` defaults to ``latest``, + # "jobs from the most recent execution of the workflow run": + # https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run + # It needs the "Actions" repository permission, read: + # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps + # ``updated`` is ``'false'`` only for a ``skipped`` job; anything else it cannot + # read leaves it unset, and ``version_check`` runs as before. + # + # The concurrency group above cannot see this: it reads only the event, and + # that Vendor Update concluded ``success``, so the run still joins ``main``'s + # group and can still displace a run pending there, as before. It used to + # evaluate ``main``'s newer tip in its place; it now releases nothing, so the + # displaced run's release waits for the next run on ``main`` -- the tip's own + # Vendor Update if the tip's Unit Tests run has not completed yet, else the + # next push's or Saturday's. + vendor_update: + name: Triggering Vendor Update + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' }} + timeout-minutes: 5 + permissions: + actions: read + outputs: + updated: ${{ steps.updated.outputs.updated }} + steps: + - name: Read whether its vendor-update job ran + id: updated + env: + GH_TOKEN: ${{ github.token }} + PCAPKIT_VENDOR_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -uo pipefail + + if ! result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PCAPKIT_VENDOR_RUN_ID}/jobs" \ + --paginate --jq '.jobs[] | select(.name == "vendor-update") | .conclusion')"; then + echo "::warning title=Vendor Update not read (#1748)::could not list the jobs of Vendor Update run ${PCAPKIT_VENDOR_RUN_ID}, so this run treats it as one that updated." + exit 0 + fi + case "$result" in + success) + echo "updated=true" >> "$GITHUB_OUTPUT" + ;; + skipped) + echo "updated=false" >> "$GITHUB_OUTPUT" + echo "Vendor Update run ${PCAPKIT_VENDOR_RUN_ID} skipped vendor-update: its verdict did not cover the commit it checked out." + ;; + *) + echo "::warning title=Vendor Update not read (#1748)::Vendor Update run ${PCAPKIT_VENDOR_RUN_ID} reports vendor-update as '${result//$'\n'/ }', so this run treats it as one that updated." + ;; + esac + version_check: name: Check Version runs-on: ubuntu-latest @@ -207,9 +279,19 @@ jobs: # second approval, and race the first run's uploads from another # concurrency group. Whether that push fires, and carries the bot's # ``github.actor_id``, is unmeasured; if not, this never matches. + # + # #1748: and a Vendor Update whose ``vendor-update`` job skipped, as + # ``vendor_update`` above reads it. ``!cancelled()`` because that job skips + # on a push, and "If a job fails or is skipped, all jobs that need it are + # skipped unless the jobs use a conditional expression that causes the job + # to continue": + # https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-jobs + needs: [ vendor_update ] if: >- - ${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && - !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }} + ${{ !cancelled() && + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && + !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) && + needs.vendor_update.outputs.updated != 'false' }} permissions: contents: read steps: @@ -444,6 +526,31 @@ jobs: echo "http $status, found $count of $expected expected platform/Python combinations for ${PCAPKIT_VERSION} on Anaconda" echo "PCAPKIT_CONDA_COMPLETE=$complete" >> "$GITHUB_OUTPUT" + # #1748: which commit the ``v*`` tag names, resolved as the #1634 check in + # ``github``, ``pypi`` and ``conda`` resolves it, so the gates below skip + # exactly the runs that check would refuse. ``PCAPKIT_RELEASED_ELSEWHERE`` + # is ``true`` only for a tag naming another commit; no tag, or this commit, + # is ``false``. + - name: Check which commit the version's tag names + id: owner + env: + PCAPKIT_TAG: v${{ steps.get_version.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" + git fetch --quiet --force origin "refs/tags/${PCAPKIT_TAG}:refs/tags/${PCAPKIT_TAG}" || true + tagged="$(git rev-parse -q --verify "refs/tags/${PCAPKIT_TAG}^{commit}" || true)" + elsewhere=false + if [ -n "$tagged" ] && [ "$tagged" != "$run_commit" ]; then + elsewhere=true + fi + + echo "${PCAPKIT_TAG} -> ${tagged:-no commit}; this run's commit is ${run_commit}; released elsewhere: ${elsewhere}" + echo "PCAPKIT_RELEASED_AT=${tagged}" >> "$GITHUB_OUTPUT" + echo "PCAPKIT_RELEASED_ELSEWHERE=${elsewhere}" >> "$GITHUB_OUTPUT" + outputs: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_PRERELEASE: ${{ steps.get_version.outputs.PCAPKIT_PRERELEASE }} @@ -452,6 +559,9 @@ jobs: PCAPKIT_PYPI_COMPLETE: ${{ steps.check_pypi.outputs.PCAPKIT_PYPI_COMPLETE }} PCAPKIT_CONDA_COMPLETE: ${{ steps.check_conda.outputs.PCAPKIT_CONDA_COMPLETE }} PCAPKIT_CONDA_LABEL: ${{ steps.get_version.outputs.PCAPKIT_CONDA_LABEL }} + # #1748: the commit the ``v*`` tag names, and whether it is another one. + PCAPKIT_RELEASED_AT: ${{ steps.owner.outputs.PCAPKIT_RELEASED_AT }} + PCAPKIT_RELEASED_ELSEWHERE: ${{ steps.owner.outputs.PCAPKIT_RELEASED_ELSEWHERE }} # #1660: from .github/python-versions.toml, for ``pypi`` and ``conda``. PYTHON_LATEST: ${{ steps.python-versions.outputs.latest }} PYTHON_PYPI: ${{ steps.python-versions.outputs.pypi }} @@ -463,13 +573,17 @@ jobs: # #1052: runs only when a release will actually happen -- a ``v*`` tag push, # or any of the four targets still incomplete by its own evidence. See the # note above ``jobs:`` for why ``!= 'true'`` and why a skip here can never - # let a publisher through. + # let a publisher through. #1748: nor on a version tagged at another commit, + # unless this is a ``v*`` push, on the same clause as ``tag``, ``pypi`` and + # ``conda``. unit-tests: name: Release test gate needs: [ version_check ] if: >- ${{ needs.version_check.result == 'success' && (startsWith(github.ref_name, 'v') || + needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && + (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS != 'true' || needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS != 'true' || needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE != 'true' || @@ -494,11 +608,18 @@ jobs: # so the implicit ``success()`` requires the gate to have passed. The # release App's private key is a ``github-release`` environment secret, so # no job reads it before this approval, and no other job reads it at all. + # #1748: a tag naming another commit is a tag that exists, so + # ``PCAPKIT_TAG_EXISTS`` already skips this job off a ``v*`` ref -- unless + # the tag was pushed between ``check_tag`` and ``owner``, which read it + # separately. The same clause as the other three covers that too, so the + # gate runs whenever any of the four would. environment: github-release permissions: contents: write needs: [ unit-tests, version_check ] - if: ${{ startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false' }} + if: >- + ${{ (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && + (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false') }} steps: - name: Checkout code uses: actions/checkout@v7 @@ -687,6 +808,7 @@ jobs: ${{ !cancelled() && needs.version_check.result == 'success' && needs.unit-tests.result == 'success' && (needs.github.result == 'success' || needs.github.result == 'skipped') && + (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS == 'false') }} steps: - name: Load maintenance deploy key @@ -852,6 +974,7 @@ jobs: ${{ !cancelled() && needs.version_check.result == 'success' && needs.unit-tests.result == 'success' && (needs.github.result == 'success' || needs.github.result == 'skipped') && + (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE == 'false') }} # #1633: each KTLO leg's own outcome, under a name only that leg sets -- # matrix job outputs are combined across legs -- so ``release_status`` can @@ -1088,6 +1211,7 @@ jobs: needs.unit-tests.result == 'success' && (needs.github.result == 'success' || needs.github.result == 'skipped') && (needs.tag.result == 'success' || needs.tag.result == 'skipped') && + (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE != 'true') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_CONDA_COMPLETE == 'false') }} strategy: fail-fast: false @@ -1390,7 +1514,7 @@ jobs: release_status: name: Release status runs-on: ubuntu-latest - needs: [ unit-tests, version_check, github, tag, pypi, conda ] + needs: [ vendor_update, unit-tests, version_check, github, tag, pypi, conda ] if: ${{ !cancelled() }} permissions: {} steps: @@ -1398,8 +1522,10 @@ jobs: env: PCAPKIT_EVENT_NAME: ${{ github.event_name }} PCAPKIT_WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + PCAPKIT_VENDOR_UPDATED: ${{ needs.vendor_update.outputs.updated }} PCAPKIT_ACTOR_ID: ${{ github.actor_id }} PCAPKIT_RELEASE_APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }} + PCAPKIT_RUN_SHA: ${{ github.sha }} PCAPKIT_UNIT_TESTS: ${{ needs.unit-tests.result }} PCAPKIT_VERSION_CHECK: ${{ needs.version_check.result }} PCAPKIT_GITHUB_JOB: ${{ needs.github.result }} @@ -1411,6 +1537,8 @@ jobs: PCAPKIT_CONDA_TAG_EXISTS: ${{ needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS }} PCAPKIT_PYPI_COMPLETE: ${{ needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE }} PCAPKIT_CONDA_COMPLETE: ${{ needs.version_check.outputs.PCAPKIT_CONDA_COMPLETE }} + PCAPKIT_RELEASED_AT: ${{ needs.version_check.outputs.PCAPKIT_RELEASED_AT }} + PCAPKIT_RELEASED_ELSEWHERE: ${{ needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE }} PCAPKIT_PYPI_KTLO_310: ${{ needs.pypi.outputs.ktlo_310 }} PCAPKIT_PYPI_KTLO_PYPY310: ${{ needs.pypi.outputs.ktlo_pypy310 }} run: | @@ -1435,13 +1563,17 @@ jobs: # itself needs attention, even though it is not #888 -- no tag exists # here that could be left stranded. On a push it is quiet only for # the release App's own tag (#1634), which version_check skips by - # design; an unset bot id never matches here. + # design; an unset bot id never matches here. #1748: a Vendor Update + # that concluded `success` without running vendor-update is quiet too. if [ "$PCAPKIT_VERSION_CHECK" = "skipped" ]; then if [ "$PCAPKIT_EVENT_NAME" = "push" ] && [ -n "$PCAPKIT_RELEASE_APP_BOT_ID" ] \ && [ "$PCAPKIT_ACTOR_ID" = "$PCAPKIT_RELEASE_APP_BOT_ID" ]; then echo "::notice title=Nothing to release::the release App's bot pushed this tag from the Create Release run that released its commit, so this run correctly released nothing (#1634)." elif [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ] && [ "$PCAPKIT_WORKFLOW_RUN_CONCLUSION" = "skipped" ]; then echo "::notice title=Nothing to release::the triggering Vendor Update run found nothing to update, so this run correctly released nothing." + elif [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ] && [ "$PCAPKIT_WORKFLOW_RUN_CONCLUSION" = "success" ] \ + && [ "${PCAPKIT_VENDOR_UPDATED:-}" = "false" ]; then + echo "::notice title=Nothing to release::the triggering Vendor Update run skipped vendor-update, since its Unit Tests verdict did not cover the commit it checked out, so it updated nothing and this run correctly released nothing (#1748)." elif [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ]; then echo "::warning title=Release skipped::the triggering Vendor Update run concluded '${PCAPKIT_WORKFLOW_RUN_CONCLUSION}', not 'success', so version_check, the release test gate and every release job were skipped. Not #888 -- no tag was created that could be left stranded -- but Vendor Update likely needs attention." else @@ -1466,6 +1598,27 @@ jobs: exit 0 fi + # #1748: the version is tagged at another commit, and the gate and + # every publisher skipped for it -- which they do only off a v* ref. + # That release is that commit's (#1634), so this run has nothing to + # do: a notice when it is complete, and a warning naming what it + # lacks when not, both green. A publisher that ran instead -- on a v* + # push -- meets #1634's check and is reported below. + if [ "${PCAPKIT_RELEASED_ELSEWHERE:-}" = "true" ] && [ "$PCAPKIT_GITHUB_JOB" = "skipped" ] \ + && [ "$PCAPKIT_TAG_JOB" = "skipped" ] && [ "$PCAPKIT_PYPI_JOB" = "skipped" ] \ + && [ "$PCAPKIT_CONDA_JOB" = "skipped" ]; then + missing="" + [ "$PCAPKIT_CONDA_TAG_EXISTS" = "true" ] || missing="${missing:+${missing}, }the conda-${PCAPKIT_VERSION}+0 tag" + [ "$PCAPKIT_PYPI_COMPLETE" = "true" ] || missing="${missing:+${missing}, }some of its PyPI files" + [ "$PCAPKIT_CONDA_COMPLETE" = "true" ] || missing="${missing:+${missing}, }some of its Anaconda files" + if [ -z "$missing" ]; then + echo "::notice title=Nothing to release::v${PCAPKIT_VERSION} names ${PCAPKIT_RELEASED_AT}, not this run's commit ${PCAPKIT_RUN_SHA}, and that release is complete, so this run correctly released nothing (#1748)." + else + echo "::warning title=v${PCAPKIT_VERSION} is unfinished (#1634)::v${PCAPKIT_VERSION} names ${PCAPKIT_RELEASED_AT}, not this run's commit ${PCAPKIT_RUN_SHA}, and still lacks ${missing}. Only that commit's run may finish it (#1634), so this run released nothing: re-run the Create Release run created for ${PCAPKIT_RELEASED_AT}, or release the next version." + fi + exit 0 + fi + # From here, version_check succeeded, so the evidence outputs above # are meaningful. A *skipped* gate (#1052) falls through on purpose: # it skips only when every evidence output reads 'true' on a non-v* diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 362c7369e..d6d0bb42b 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -50,12 +50,12 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``tests/project/test_bump_version.py``'s ``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version`` (``:494-514``) asserts the two agree, and ``create-release.yml``'s -``unit-tests`` job (``:467-481``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:579-595``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1776-1846``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every -publishing job requires it to have succeeded (``:500,685,850,1085``), so a +publishing job requires it to have succeeded (``:619,806,972,1208``), so a citation left behind fails the gate well before any approval is requested. Either run the script, or move both fields by hand in the same commit. @@ -66,7 +66,7 @@ process: the ``changelog`` job (``unit-tests.yml:1624-1643``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" -(``create-release.yml:554-555``). Both enforce what the changelog convention +(``create-release.yml:675-676``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -79,12 +79,12 @@ neither is a workaround for the other: ``push: tags: ['v*']`` Pushing a tag matching ``v*`` yourself. This chooses **which commit** to release, not which version: ``version_check``'s checkout - (``:216-220``) pins ``ref: ${{ github.sha }}``, which on this trigger is + (``:298-302``) pins ``ref: ${{ github.sha }}``, which on this trigger is the commit the pushed tag points at, and reads ``pcapkit.__version__`` out - of *that* tree (``:245``). Every downstream tag and release name is built + of *that* tree (``:327``). Every downstream tag and release name is built from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}`` - is ``github``'s ``tag_name`` at ``:596`` and the release ``pypi`` and - ``conda`` upload to at ``:992`` and ``:1338``, always from + is ``github``'s ``tag_name`` at ``:717`` and the release ``pypi`` and + ``conda`` upload to at ``:1115`` and ``:1462``, always from ``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version actually released is whatever ``__version__`` says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag @@ -124,6 +124,13 @@ conclude ``success`` is keyed on its own ``github.run_id`` instead (`#1705 `__). It releases nothing, so it neither waits behind a release nor displaces a run waiting there; only a newer run that may release, reading ``main``'s newer tip, can. +A Vendor Update that concluded ``success`` with ``vendor-update`` skipped -- +its Unit Tests verdict was for another commit -- releases nothing either since +`#1748 `__: the +``vendor_update`` job reads that run's jobs (``:230-264``), and +``version_check`` skips. The group cannot see that, so such a run still joins +the ref's group; if it displaces a pending run, that release waits for the +next Vendor Update on ``main``. One Commit per Release ---------------------- @@ -137,19 +144,26 @@ days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and the default branch, and ``tag`` pulled ``main`` before tagging. * On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes - ``target_commitish`` ``github.sha`` (``:609``); on a ``v*`` push the tag + ``target_commitish`` ``github.sha`` (``:730``); on a ``v*`` push the tag already exists and it passes none. Either way it then checks that the tag names that commit. -* ``tag`` cuts ``conda-+0`` at that commit (``:722-749``) before it +* ``tag`` cuts ``conda-+0`` at that commit (``:844-871``) before it reads ``main`` at all; the :file:`conda/build` reset is a separate commit on - top of ``main`` (``:771-824``), described under `Precautions`_. + top of ``main`` (``:893-946``), described under `Precautions`_. * ``pypi`` and ``conda`` check out that commit and, before building, check that every tag they publish under names it, as ``tag`` does for the ``v*`` - tag. A run created for a *later* commit of the same version therefore fails - with an ``::error`` instead of publishing a second tree under that version; - a partial release is finished by re-running the run that tagged it. + tag. A run created for a *later* commit of the same version therefore never + publishes a second tree under that version. ``version_check`` resolves the + ``v*`` tag the same way first (``:534-552``), and off a ``v*`` ref a tag + naming another commit skips the gate and all four publishers + (`#1748 `__): + ``Release status`` posts a ``::notice`` if that release is complete, or a + ``::warning`` naming both commits and what is missing (``:1607-1617``), and + the run stays green. A ``v*`` push of such a version still fails with the + check's ``::error``. A partial release is finished by re-running the run that + tagged it. * ``pypi`` and ``conda`` attach their files with ``gh release upload`` - (``:985-1022``, ``:1333-1368``), not ``action-gh-release``, which calls Update + (``:1108-1145``, ``:1457-1492``), not ``action-gh-release``, which calls Update a release before every upload (3.0.2's ``src/github.ts:631-683``). A file the release already has is left alone (`#1640 `__): the action @@ -243,11 +257,11 @@ The owner's choice on `#1634 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:569-577``) runs +(``create-release.yml:690-698``) 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 (``:613``). +``post`` step (v3.3.0's README). Only Create Release spends it (``:734``). 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 @@ -275,15 +289,15 @@ One-time setup: Unlike ``GITHUB_TOKEN``'s, the App's tag starts a tag-push run. It would rerun the release test gate, ask for a second approval and race the first run's uploads from another concurrency group, so ``version_check`` skips a ``push`` -whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:210-212``), and +whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:290-294``), and ``release_status`` reports it with ``::notice title=Nothing to release`` -(``:1440-1442``). A tag pushed by a person runs as before. Whether a tag made +(``:1569-1571``). A tag pushed by a person runs as before. Whether a tag made through the Releases API fires ``push``, and with the bot as actor, is unmeasured; if not, the guard never matches and the cost is one approval to reject. A refused Create Release (404, or 403 "Resource not accessible by -integration") is explained by "Explain a refused release tag" (``:646-658``) +integration") is explained by "Explain a refused release tag" (``:767-779``) according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; with the App's, it points at the App's permissions and installation. For a final release, first delete any draft ``v`` the refused run left: @@ -432,8 +446,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``, removed on `#887 `__. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github`` -(``.github/workflows/create-release.yml:685,850``), and ``conda`` depends on -``tag`` and ``github`` (``:1085``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:806,972``), and ``conda`` depends on +``tag`` and ``github`` (``:1208``) -- so nothing downstream of ``github`` can start before it is approved, and rejecting it leaves nothing tagged and nothing published. Before this change ``tag`` depended on ``version_check`` alone, so removing its reviewer without moving this dependency would have let @@ -457,8 +471,8 @@ Precautions *their own* artefact is missing rather than whether the ``v*`` tag exists, so an incomplete release runs the jobs that did not finish instead of skipping them. A half-finished release self-heals on re-running the run that tagged it - -- see `Recovery`_ below; a run created for a later commit refuses instead - (`One Commit per Release`_). + -- see `Recovery`_ below; a run created for a later commit releases nothing + instead, with a warning that names what is missing (`One Commit per Release`_). ``release_status`` is the other half: it runs unconditionally and reports, with a ``::notice``, a ``::warning`` or a failing ``::error``, why a run released nothing or that it released something, so a stranded release is @@ -480,7 +494,7 @@ back, and neither is needed, since a plain re-run self-heals; see **``environment: pypi`` stays even though its reviewer is gone.** ``pypi`` publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at -``:845``, ``id-token: write`` at ``:849``), and PyPI's trusted-publisher +``:967``, ``id-token: write`` at ``:971``), and PyPI's trusted-publisher configuration can be scoped to a GitHub Actions environment name. If this project's publisher on PyPI is scoped that way, removing the ``pypi`` name -- not just its reviewer -- would break the upload with a claim mismatch; @@ -491,7 +505,7 @@ the name regardless, since there is no upside to removing it. **``conda-tag`` writes to ``main``, so a release is not read-only on the branch.** After cutting ``conda-+0`` at the run's commit, the ``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of -``main`` (``.github/workflows/create-release.yml:771-824``) -- a plain +``main`` (``.github/workflows/create-release.yml:893-946``) -- a plain fast-forward push, never a rewrite -- so the next Conda Update bump of that version is ``+1``. There is none when the run's commit is not on ``main``, nor when ``main``'s tip already says ``0`` (the usual case, since diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 2916000de..8dc8fb517 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -256,7 +256,10 @@ the verdict for 9083163f5 (`#1643 `__). The rule and its evidence are in ``util/verdict_covers_checkout.py``. Conda Update also writes after the checkout, so it applies the same rule a second time: its push rebases onto ``main``'s tip only when the commits that brings in pass it, and otherwise @@ -283,7 +286,7 @@ the caller's own workflow run, as one of the caller's own jobs, rather than starting an independent run after the caller finishes. All four calls in this repository target the same reusable workflow, with the same input: -* ``.github/workflows/create-release.yml:479`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:593`` -- 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``. @@ -323,35 +326,43 @@ The Skip Cascade (`#888 `__) ------------------------------------------------------------------------------- The two relationships above compose into a failure mode worth seeing on its -own graph. Create Release's first job, ``version_check`` -- ahead of the -``uses:`` call above -- carries: +own graph. Create Release's ``version_check`` -- ahead of the ``uses:`` call +above -- carries: .. code-block:: yaml if: >- - ${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && - !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }} + ${{ !cancelled() && + (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && + !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) && + needs.vendor_update.outputs.updated != 'false' }} -(``create-release.yml:210-212``). Its second half skips the tag-push run that +(``create-release.yml:290-294``). Its third clause skips the tag-push run that the release App's own tag starts (:doc:`releasing`, "The Release App"). On the ``workflow_run`` path, this evaluates **false**, and the job is skipped outright, whenever the upstream Vendor Update run's own conclusion was anything other than ``success`` -- including ``skipped``, which is exactly what Vendor Update's *own* jobs report when *their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s -``vendor-update`` job). ``unit-tests`` then depends on that job with -``needs: [ version_check ]`` (``create-release.yml:469``) and an ``if:`` that +``vendor-update`` job). Since +`#1748 `__ it is also false +for a Vendor Update that concluded ``success`` with ``vendor-update`` skipped -- +its ``verdict`` job found the Unit Tests verdict was for another commit -- which +the ``vendor_update`` job reads from that run's jobs first, as run 38650191283 +was. ``!cancelled()`` keeps a skipped ``vendor_update``, on a push, from +skipping it. ``unit-tests`` then depends on that job with +``needs: [ version_check ]`` (``create-release.yml:581``) and an ``if:`` that calls no status-check function, so a skipped dependency skips it too -- and every job past it is gated the same way: ``github`` needs ``[ unit-tests, -version_check ]`` (``:500``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:685``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:850``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:1085``). +version_check ]`` (``:619``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:806``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:972``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:1208``). .. mermaid:: flowchart TD - WR["Vendor Update completed
conclusion != success"] + WR["Vendor Update completed
conclusion != success,
or vendor-update skipped"] VC["version_check
SKIPPED"] UTX["unit-tests (uses: unit-tests.yml)
SKIPPED"] GH["github (GitHub Release)
SKIPPED"] @@ -372,7 +383,7 @@ unit-tests, version_check ]`` (``:1085``). This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and ``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi`` -(``create-release.yml:685,850``) and of ``conda`` (``:1085``), but ``github`` +(``create-release.yml:806,972``) and of ``conda`` (``:1208``), but ``github`` already implies those edges, since it depends on both itself. Drawing them would add lines without changing which job can start before which, or which job a skip in ``version_check`` reaches. @@ -400,7 +411,11 @@ Such a run is also kept out of the release's concurrency group its own ``github.run_id`` rather than on the ref, so it cannot cancel a run waiting there with release work. Keyed on the ref, run 38499295798 joined at 02:06:32Z on 2026-10-14, and the pending 38499150441, whose Vendor Update had -succeeded, was cancelled a second later. +succeeded, was cancelled a second later. A run whose Vendor Update concluded +``success`` with ``vendor-update`` skipped (#1748) is still keyed on the ref: +the group reads only the event, which does not say which jobs ran. It can still +displace a run pending there, and now releases nothing in its place, so that +release waits for the next Vendor Update on ``main``. ``pull_request_target`` ----------------------- @@ -814,10 +829,10 @@ Deployment ``environment:`` Five jobs declare a deployment ``environment:`` and can therefore pause for an approval, four in ``create-release.yml`` and one in ``cron-python-versions.yml``: -* ``github`` -- ``environment: github-release`` (``create-release.yml:497``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:683``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:845``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1082``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:616``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:804``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:967``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1205``) * ``pr`` -- ``environment: github-release``, for the release App's key (see `Python Versions`_) diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py index c714aadf1..e493eaee6 100644 --- a/tests/project/test_python_versions_update_1660.py +++ b/tests/project/test_python_versions_update_1660.py @@ -1252,10 +1252,12 @@ def test_version_check_treats_its_merge_like_any_other(self) -> None: self.assertEqual(set(on), {'push', 'workflow_run'}) self.assertEqual(on['push'], {'tags': ['v*']}) self.assertEqual(on['workflow_run']['workflows'], ['Vendor Update']) + # #1748 added the triggering Vendor Update's own reading, which names no branch either. self.assertEqual(' '.join(spec['jobs']['version_check']['if'].split()), - "${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == " - "'success') && !(github.event_name == 'push' && github.actor_id == " - "vars.RELEASE_APP_BOT_ID) }}") + "${{ !cancelled() && (github.event_name != 'workflow_run' || " + "github.event.workflow_run.conclusion == 'success') && !(github.event_name == 'push' " + "&& github.actor_id == vars.RELEASE_APP_BOT_ID) && " + "needs.vendor_update.outputs.updated != 'false' }}") def test_permissions_are_nothing_by_default_and_little_per_job(self) -> None: self.assertEqual(self.spec['permissions'], {}) diff --git a/tests/project/test_release_concurrency_1705.py b/tests/project/test_release_concurrency_1705.py index 309ba4f56..fe6ede45d 100644 --- a/tests/project/test_release_concurrency_1705.py +++ b/tests/project/test_release_concurrency_1705.py @@ -25,9 +25,9 @@ import re import unittest -from tests.project.test_release_gates import (HUMAN_ACTOR_ID, NULL, RELEASE_APP_BOT_ID, WORKFLOW, - declared_if, evaluate_condition, evaluate_expression, - job_blocks) +from tests.project.test_release_gates import (HUMAN_ACTOR_ID, NULL, RELEASE_APP_BOT_ID, STATUS, + WORKFLOW, declared_if, evaluate_condition, + evaluate_expression, job_blocks) #: The workflow-level ``concurrency:`` block: a one-line ``group:`` holding one #: interpolation after a literal prefix, then ``cancel-in-progress:``. @@ -48,12 +48,20 @@ def run_context(run_id: 'str', event_name: 'str' = 'workflow_run', conclusion: 'object' = 'success', ref: 'str' = MAIN, - actor_id: 'str' = HUMAN_ACTOR_ID) -> 'dict[str, object]': - """One run's ``github``/``vars`` context; a ``push`` carries no ``workflow_run``.""" + actor_id: 'str' = HUMAN_ACTOR_ID, updated: 'object' = 'true') -> 'dict[str, object]': + """One run's ``github``/``vars`` context; a ``push`` carries no ``workflow_run``. + + #1748: and ``vendor_update``'s ``updated``, which ``version_check`` reads, + held at ``'true'`` -- a Vendor Update that ran ``vendor-update`` -- unless a + test says otherwise. + + """ event = {} # type: dict[str, object] if event_name == 'workflow_run': event = {'workflow_run': {'conclusion': conclusion}} return { + 'needs': {'vendor_update': {'outputs': {'updated': updated}}}, + STATUS: {'cancelled': False}, 'github': { 'event_name': event_name, 'ref': ref, @@ -116,6 +124,24 @@ def test_runs_that_may_release_still_wait_for_each_other(self) -> None: context = run_context(run_id, event_name='push', ref=TAG, actor_id=actor_id) self.assertEqual(self.group(context), f'create-release-{TAG}') + def test_a_vendor_update_that_updated_nothing_still_joins_mains_group(self) -> None: + """#1748: the group reads the event only, so it cannot see ``vendor_update``. + + Vendor Update 38650191283 concluded ``success`` with ``vendor-update`` + skipped. Its run now skips ``version_check`` but still waits in, and can + still displace a run pending in, ``main``'s group -- the one case where + the two differ, recorded so that a change to either is a decision. The + workflow's ``vendor_update`` comment gives what that costs. + + """ + context = run_context('38650191283', updated='false') + self.assertFalse(evaluate_condition(self.version_check, context)) + self.assertEqual(self.group(context), f'create-release-{MAIN}') + for updated in ('true', NULL): + with self.subTest(updated=updated): + self.assertTrue(evaluate_condition(self.version_check, + run_context('1', updated=updated))) + def test_the_scanner_agrees_with_yaml(self) -> None: """What :data:`_CONCURRENCY` reads is what a YAML parse reads.""" try: diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 6cf65160f..4ec815b3b 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -1075,6 +1075,11 @@ def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool' HUMAN_ACTOR_ID = '1000001' RELEASE_APP_BOT_ID = '2000002' +#: #1748's shape: a run created for ``0cdd3cdd1``, while ``v1.5.0b12`` names +#: ``2025da5e5``, the commit whose run tagged it. +RUN_COMMIT = '0cdd3cdd1d47f76344f62e739c598ae3b78a83eb' +TAGGED_COMMIT = '2025da5e57dc82b1a399ea4de7dd5be06c96ad88' + def release_context(version_check: 'str' = 'success', unit_tests: 'str' = 'success', @@ -1088,7 +1093,10 @@ def release_context(version_check: 'str' = 'success', event_name: 'str' = 'workflow_run', workflow_run_conclusion: 'str' = 'success', actor_id: 'str' = HUMAN_ACTOR_ID, - release_app_bot_id: 'str' = RELEASE_APP_BOT_ID) -> 'dict[str, object]': + release_app_bot_id: 'str' = RELEASE_APP_BOT_ID, + released_elsewhere: 'str' = 'false', + vendor_update: 'str' = 'success', + vendor_updated: 'object' = 'true') -> 'dict[str, object]': """The ``needs``/``github``/status context one scenario row stands for. Every default is a value that, on its own, lets a gate run, so a row @@ -1123,11 +1131,20 @@ def release_context(version_check: 'str' = 'success', (#1634). ``release_app_bot_id=''`` is the variable unset, which the ``vars`` docs give as "an empty string", not ``null``. + #1748: ``released_elsewhere`` is ``PCAPKIT_RELEASED_ELSEWHERE``, whether the + ``v*`` tag names a commit other than this run's -- ``'false'``, the value + that lets every gate run, unless a row says otherwise, and ``null`` when + named in ``unknown``. ``vendor_update`` is that job's result, ``'skipped'`` + on a ``push``; ``vendor_updated`` is its ``updated``: ``'true'``, + ``'false'`` for a Vendor Update that skipped ``vendor-update``, or + :data:`NULL` for one it could not read or a ``vendor_update`` that skipped. + """ outputs = {} # type: dict[str, object] if outputs_present: outputs = {name: ('true' if name in complete else 'false') for name in GATE_EVIDENCE.values()} + outputs['PCAPKIT_RELEASED_ELSEWHERE'] = released_elsewhere outputs.update({name: NULL for name in unknown}) outputs['PCAPKIT_VERSION'] = '1.5.0' outputs['PCAPKIT_PRERELEASE'] = 'false' @@ -1135,6 +1152,7 @@ def release_context(version_check: 'str' = 'success', return { 'needs': { + 'vendor_update': {'result': vendor_update, 'outputs': {'updated': vendor_updated}}, 'unit-tests': {'result': unit_tests}, 'version_check': {'result': version_check, 'outputs': outputs}, 'github': {'result': github}, @@ -1193,6 +1211,11 @@ def gate_rows(job: 'str') -> 'list[tuple[str, dict[str, object], bool]]': the evidence clause is reached. The other three outputs stay ``'false'``, so a gate reading somebody else's evidence runs where this row demands a skip, and the row keeps the cross-evidence trap the rows above set. + * **#1748's three: the version tagged at another commit**, on ``main`` + (skip) and on a ``v*`` push (run, into #1634's check), with this job's own + evidence still incomplete so that the new clause alone decides; and + ``PCAPKIT_RELEASED_ELSEWHERE`` at ``null``, which runs -- the one row that + tells ``!= 'true'`` from ``== 'false'`` for it. Held false in every row but the two cancellation rows: ``cancelled()``. Holding it *true* elsewhere would make each row pass for any mutation that @@ -1250,6 +1273,13 @@ def gate_rows(job: 'str') -> 'list[tuple[str, dict[str, object], bool]]': rows.append((f'ref_name=main, {evidence}=null', release_context(unknown=(evidence,)), False)) + + for ref_name in ('main', 'v1.5.0'): + rows.append((f'ref_name={ref_name}, v1.5.0 tagged at another commit', + release_context(ref_name=ref_name, released_elsewhere='true'), + ref_name.startswith('v'))) + rows.append(('ref_name=main, PCAPKIT_RELEASED_ELSEWHERE=null', + release_context(unknown=('PCAPKIT_RELEASED_ELSEWHERE',)), True)) return rows @@ -1822,7 +1852,8 @@ def test_the_scan_found_the_jobs(self) -> None: """A scanner that finds nothing would pass every test below it.""" self.assertEqual( sorted(self.jobs), - ['conda', 'github', 'pypi', 'release_status', 'tag', 'unit-tests', 'version_check'], + ['conda', 'github', 'pypi', 'release_status', 'tag', 'unit-tests', 'vendor_update', + 'version_check'], 'the job list moved; if that is intended, the expectations in this ' 'module need revisiting rather than this line being updated alone', ) @@ -1947,8 +1978,9 @@ def gate_coverage_violations(gate: 'str', jobs: 'dict[str, str]') -> 'list[str]' """Every evidence state in which a publisher would run and the gate would not. Walks both refs, every ``'true'``/``'false'``/``null`` assignment of the four - evidence outputs, and ``github``/``tag`` at ``success`` and at ``skipped`` -- - 648 contexts -- with ``version_check`` and ``unit-tests`` themselves at + evidence outputs and of ``PCAPKIT_RELEASED_ELSEWHERE`` (#1748), and + ``github``/``tag`` at ``success`` and at ``skipped`` -- 1944 contexts -- with + ``version_check`` and ``unit-tests`` themselves at ``success``, since the question is what the gate's own ``if:`` decides. ``github``'s condition is evaluated as declared; its implicit ``success()`` only ever makes it run *less*, so this over-approximates when it runs, @@ -1959,21 +1991,27 @@ def gate_coverage_violations(gate: 'str', jobs: 'dict[str, str]') -> 'list[str]' gate_tree = parse_condition(gate) publishers = {name: parse_condition(declared_if(jobs[name])) for name in GATE_EVIDENCE} violations = [] # type: list[str] + # #1748: and ``PCAPKIT_RELEASED_ELSEWHERE`` over the same three values, + # 1944 contexts in all. for ref_name in ('main', 'v1.5.0'): for states in itertools.product(('true', 'false', 'null'), repeat=len(names)): - for github, tag in itertools.product(('success', 'skipped'), repeat=2): - context = release_context( - ref_name=ref_name, github=github, tag=tag, - complete=tuple(n for n, v in zip(names, states) if v == 'true'), - unknown=tuple(n for n, v in zip(names, states) if v == 'null'), - ) - running = sorted(name for name, tree in publishers.items() - if _truthy(_evaluate(tree, context))) - if running and not _truthy(_evaluate(gate_tree, context)): - violations.append( - f'ref_name={ref_name}, github={github}, tag={tag}, ' - f'{dict(zip(names, states))}: {running} would run, the gate would not' + for elsewhere in ('true', 'false', 'null'): + for github, tag in itertools.product(('success', 'skipped'), repeat=2): + context = release_context( + ref_name=ref_name, github=github, tag=tag, + complete=tuple(n for n, v in zip(names, states) if v == 'true'), + unknown=tuple(n for n, v in zip(names, states) if v == 'null') + + (('PCAPKIT_RELEASED_ELSEWHERE',) if elsewhere == 'null' else ()), + released_elsewhere=elsewhere, ) + running = sorted(name for name, tree in publishers.items() + if _truthy(_evaluate(tree, context))) + if running and not _truthy(_evaluate(gate_tree, context)): + violations.append( + f'ref_name={ref_name}, github={github}, tag={tag}, ' + f'released elsewhere={elsewhere}, ' + f'{dict(zip(names, states))}: {running} would run, the gate would not' + ) return violations @@ -2063,6 +2101,28 @@ def test_the_gate_skips_when_everything_is_already_published(self) -> None: with self.subTest(job=job): self.assertFalse(evaluate_condition(declared_if(self.jobs[job]), context)) + def test_a_version_tagged_elsewhere_releases_nothing_off_a_v_tag(self) -> None: + """#1748: the gate and all four publishers skip for ``v1.5.0b12``'s shape. + + Its tag names ``2025da5e5`` and Anaconda holds 1 of its 10 files, so + ``conda`` used to run on every later commit -- after the gate -- and fail + #1634's check on each leg. ``github`` skipped there already, the tag + existing; its copy of the clause is for a tag pushed between + ``check_tag`` and ``owner``, which :meth:`test_the_gate_runs_whenever_any_publisher_would` + finds without it. A ``v*`` push of it still reaches that check. + + """ + shape = dict(released_elsewhere='true', + complete=('PCAPKIT_TAG_EXISTS', 'PCAPKIT_CONDA_TAG_EXISTS', 'PCAPKIT_PYPI_COMPLETE')) + context = release_context(**shape) # type: ignore[arg-type] + for job in ('unit-tests',) + tuple(GATE_EVIDENCE): + with self.subTest(job=job): + self.assertFalse(evaluate_condition(declared_if(self.jobs[job]), context)) + pushed = release_context(ref_name='v1.5.0', **shape) # type: ignore[arg-type] + for job in ('unit-tests', 'github', 'conda'): + with self.subTest(job=job, ref_name='v1.5.0'): + self.assertTrue(evaluate_condition(declared_if(self.jobs[job]), pushed)) + def test_dropping_any_gate_clause_is_caught(self) -> None: """The coverage check above has to be able to fail. @@ -2376,6 +2436,12 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): release_context(ref_name='v1.5.0', complete=('PCAPKIT_TAG_EXISTS',)), True), ('version_check published no outputs at all', release_context(outputs_present=False), False), + # #1748: the tag pushed at another commit between ``check_tag`` and + # ``owner``, so the two disagree; and the same on a ``v*`` push. + ('v1.5.0 tagged at another commit after check_tag read it', + release_context(released_elsewhere='true'), False), + ('a v* tag push of a version tagged at another commit', + release_context(ref_name='v1.5.0', released_elsewhere='true'), True), ] #: ``(row name, context, whether version_check must run)``. Its ``if:`` @@ -2403,6 +2469,30 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): release_context(workflow_run_conclusion='failure'), False), ('Vendor Update was cancelled', release_context(workflow_run_conclusion='cancelled'), False), + # #1748: ``vendor_update``'s reading of the triggering run. + ('Vendor Update succeeded without running vendor-update -- run 38650191283', + release_context(vendor_updated='false'), False), + ("Vendor Update's jobs could not be read", + release_context(vendor_updated=NULL), True), + ('a v* tag push, so vendor_update skipped', + release_context(event_name='push', vendor_update='skipped', vendor_updated=NULL), True), + ] + + #: ``(row name, context, whether version_check runs)``, through + #: :func:`job_runs` rather than the ``if:`` alone. ``vendor_update`` skips + #: on every ``push``, and an ``if:`` calling no status-check function + #: inherits that skip (:func:`cascade_skipped_by`), so only the + #: ``!cancelled()`` opening ``version_check``'s keeps a ``v*`` push + #: releasing. ``VERSION_CHECK_ROWS`` cannot see that: ``!cancelled()`` is + #: true on every one of its rows. + VERSION_CHECK_CASCADE_ROWS = [ + ('a v* tag push, vendor_update skipped', + release_context(event_name='push', ref_name='v1.5.0', vendor_update='skipped', + vendor_updated=NULL), True), + ('Vendor Update skipped vendor-update, so updated=false', + release_context(vendor_updated='false'), False), + ("Vendor Update's jobs could not be read, so updated is unset", + release_context(vendor_updated=NULL), True), ] #: ``(row name, context, whether the release test gate must run)``. #1052: @@ -2427,6 +2517,15 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): release_context(version_check='failure'), False), ('version_check skipped, no outputs', release_context(version_check='skipped', outputs_present=False), False), + # #1748: the version is tagged at another commit, its release unfinished. + ('v1.5.0 tagged at another commit, Anaconda incomplete', + release_context(released_elsewhere='true', + complete=('PCAPKIT_TAG_EXISTS', 'PCAPKIT_CONDA_TAG_EXISTS', + 'PCAPKIT_PYPI_COMPLETE')), False), + ('a v* tag push of a version tagged at another commit', + release_context(ref_name='v1.5.0', released_elsewhere='true'), True), + ('whether the tag names another commit unknown', + release_context(unknown=('PCAPKIT_RELEASED_ELSEWHERE',)), True), ] def _assert_rows(self, job: 'str', @@ -2451,7 +2550,7 @@ def test_the_tables_are_not_empty(self) -> None: runs, which is #888 rather than a fix for it. """ - for job, expected in (('tag', 22), ('pypi', 22), ('conda', 26)): + for job, expected in (('tag', 25), ('pypi', 25), ('conda', 29)): with self.subTest(job=job): rows = gate_rows(job) self.assertEqual( @@ -2510,6 +2609,44 @@ def test_version_check_requires_a_successful_vendor_update(self) -> None: """``version_check``'s gate, the outermost one in the file.""" self._assert_rows('version_check', self.VERSION_CHECK_ROWS) + def test_version_check_runs_past_a_vendor_update_that_skipped(self) -> None: + """#1748: whether ``version_check`` *runs*, cascade included. + + Each row's ``vendor_update`` result is checked against that job's own + ``if:`` first, so "it skips on a push" is read from the file rather + than assumed. + + """ + for name, context, expected in self.VERSION_CHECK_CASCADE_ROWS: + with self.subTest(scenario=name): + needs = context['needs'] + assert isinstance(needs, dict) + entry = needs['vendor_update'] + assert isinstance(entry, dict) + self.assertEqual(job_runs(self.jobs, 'vendor_update', context), + entry['result'] == 'success') + self.assertEqual( + job_runs(self.jobs, 'version_check', context), expected, + f'`version_check` should {"run" if expected else "skip"} for {name!r}', + ) + + def test_dropping_version_checks_cancelled_guard_skips_a_v_tag_push(self) -> None: + """The mutation the cascade rows exist for, applied and run. + + Without ``!cancelled()`` the ``if:`` calls no status-check function, so + the implicit ``success()`` applies and the skipped ``vendor_update`` + skips ``version_check`` -- and every publisher after it -- on every + ``v*`` push. Exactly the push row has to notice. + + """ + condition = declared_if(self.jobs['version_check']) + mutant = condition.replace('!cancelled() && ', '', 1) + self.assertNotEqual(mutant, condition) + jobs = {**self.jobs, 'version_check': with_condition(self.jobs['version_check'], mutant)} + disagreements = [name for name, context, expected in self.VERSION_CHECK_CASCADE_ROWS + if job_runs(jobs, 'version_check', context) != expected] + self.assertEqual(disagreements, ['a v* tag push, vendor_update skipped'], mutant) + def test_the_release_test_gate_runs_only_when_something_is_publishable(self) -> None: """``unit-tests``'s gate, #1052.""" self._assert_rows('unit-tests', self.UNIT_TESTS_ROWS) @@ -2659,6 +2796,18 @@ class TestGateMutationsChangeTheTruthTable(WorkflowMixin, unittest.TestCase): "needs.unit-tests.result == 'success'", "(needs.unit-tests.result == 'success' || needs.unit-tests.result == 'skipped')", ('evaluator', 'text')), + # #1748's clause, which only the evaluator reads. + ('the released-elsewhere conjunct is dropped entirely', + "(startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE " + "!= 'true') && ", '', + ('evaluator',)), + ('the released-elsewhere conjunct loses its v* exemption', + "startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE", + 'needs.version_check.outputs.PCAPKIT_RELEASED_ELSEWHERE', + ('evaluator',)), + ("the released-elsewhere check becomes == 'false', so an unset output skips", + "PCAPKIT_RELEASED_ELSEWHERE != 'true'", "PCAPKIT_RELEASED_ELSEWHERE == 'false'", + ('evaluator',)), ] def _mutate(self, condition: 'str', job: 'str', old: 'str', new: 'str') -> 'str': @@ -2988,7 +3137,7 @@ def test_it_depends_directly_on_every_other_job(self) -> None: """ self.assertEqual( declared_needs(self.jobs['release_status']), - ['unit-tests', 'version_check', 'github', 'tag', 'pypi', 'conda'], + ['vendor_update', 'unit-tests', 'version_check', 'github', 'tag', 'pypi', 'conda'], ) def test_it_runs_regardless_of_what_upstream_did(self) -> None: @@ -3071,6 +3220,11 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', 'PCAPKIT_CONDA_COMPLETE': 'false', + # #1748: the tag on this run's commit, and a Vendor Update that ran. + 'PCAPKIT_VENDOR_UPDATED': 'true', + 'PCAPKIT_RUN_SHA': RUN_COMMIT, + 'PCAPKIT_RELEASED_AT': RUN_COMMIT, + 'PCAPKIT_RELEASED_ELSEWHERE': 'false', } env.update(overrides) return subprocess.run( # type: ignore[return-value] @@ -3222,8 +3376,91 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] '::error title=Stranded partial release (#888)', 1, ), + ( + # #1748: v1.5.0b12's shape on a later commit -- the tag names + # another commit, Anaconda is short, and the gate and every + # publisher skipped for it. Green, with a warning. + 'tag_on_another_commit_release_unfinished', + { + 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_CONDA_TAG_EXISTS': 'true', + 'PCAPKIT_PYPI_COMPLETE': 'true', + 'PCAPKIT_RELEASED_ELSEWHERE': 'true', 'PCAPKIT_RELEASED_AT': TAGGED_COMMIT, + }, + '::warning title=v1.5.0 is unfinished (#1634)', + 0, + ), + ( + 'tag_on_another_commit_release_complete', + { + 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_CONDA_TAG_EXISTS': 'true', + 'PCAPKIT_PYPI_COMPLETE': 'true', 'PCAPKIT_CONDA_COMPLETE': 'true', + 'PCAPKIT_RELEASED_ELSEWHERE': 'true', 'PCAPKIT_RELEASED_AT': TAGGED_COMMIT, + }, + f"::notice title=Nothing to release::v1.5.0 names {TAGGED_COMMIT}, not this run's commit " + f'{RUN_COMMIT}, and that release is complete', + 0, + ), + ( + # A v* push of that version: conda ran into #1634's check and + # failed, which stays a failure in this run's checks. + 'tag_on_another_commit_and_a_publisher_ran', + { + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'failure', + 'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_CONDA_TAG_EXISTS': 'true', + 'PCAPKIT_PYPI_COMPLETE': 'true', + 'PCAPKIT_RELEASED_ELSEWHERE': 'true', 'PCAPKIT_RELEASED_AT': TAGGED_COMMIT, + }, + '::warning title=Release did not complete', + 0, + ), + ( + # The #888 shape where version_check left no owner behind: still loud. + 'stranded_partial_with_no_owner_output', + { + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + 'PCAPKIT_TAG_EXISTS': 'true', + 'PCAPKIT_RELEASED_ELSEWHERE': '', 'PCAPKIT_RELEASED_AT': '', + }, + '::error title=Stranded partial release (#888)', + 1, + ), + ( + # #1748's side finding: Vendor Update 38650191283 concluded + # `success` with vendor-update skipped, so version_check skipped. + 'vendor_update_updated_nothing', + { + 'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_VENDOR_UPDATED': 'false', + 'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_CONDA_JOB': 'skipped', + }, + '::notice title=Nothing to release::the triggering Vendor Update run skipped vendor-update', + 0, + ), ] + def test_a_version_tagged_elsewhere_names_both_commits_and_the_remedy(self) -> None: + """#1748: the warning, whole -- the tagged commit, this one, what is missing, and what to do.""" + overrides = dict(next(row[1] for row in self.VECTORS + if row[0] == 'tag_on_another_commit_release_unfinished')) + overrides.update({'PCAPKIT_VERSION': '1.5.0b12', 'PCAPKIT_PYPI_COMPLETE': 'false'}) + result = self._run(overrides) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [ + f'::warning title=v1.5.0b12 is unfinished (#1634)::v1.5.0b12 names {TAGGED_COMMIT}, not this ' + f"run's commit {RUN_COMMIT}, and still lacks some of its PyPI files, some of its Anaconda files. " + f"Only that commit's run may finish it (#1634), so this run released nothing: re-run the Create " + f'Release run created for {TAGGED_COMMIT}, or release the next version.', + ]) + def test_the_release_apps_tag_push_is_quiet(self) -> None: """#1634: a notice naming the App, and no warning at all.""" overrides = dict(next(row[1] for row in self.VECTORS if row[0] == 'release_app_pushed_the_tag')) diff --git a/tests/project/test_release_reads_the_vendor_update_1748.py b/tests/project/test_release_reads_the_vendor_update_1748.py new file mode 100644 index 000000000..9b4bfa08d --- /dev/null +++ b/tests/project/test_release_reads_the_vendor_update_1748.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""#1748: a Vendor Update that skipped ``vendor-update`` is not a real one to Create Release. + +Vendor Update 38650191283 was raised by the Unit Tests verdict for an older +commit than the tip it checked out, so its ``verdict`` job read "not covered" +and ``vendor-update`` skipped -- yet the run concluded ``success``, the +conclusion Create Release's ``workflow_run`` trigger proceeds on, and +``0cdd3cdd1`` got a second Create Release run. Read live with ``gh api`` while +writing this: that run's jobs were ``Verdict covers this commit`` ``success``, +``Vendor test gate`` ``skipped`` and ``vendor-update`` ``skipped``; the run +covering that commit, 38650782750, had ``vendor-update`` ``success``. + +A run concludes ``skipped`` only when nothing in it ran, and ``verdict`` has to +run to decide, so Vendor Update cannot conclude anything but ``success`` there +without going red or cancelled. Create Release's ``vendor_update`` job reads the +triggering run's jobs instead, and ``version_check`` skips on ``updated == +'false'``. Its script runs here against a stand-in ``gh`` that applies the real +``--jq`` filter with ``jq``. + +""" + +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess +import tempfile +import unittest +from typing import TYPE_CHECKING + +from tests._support import scale_timeout +from tests.project.test_release_gates import (NULL, WORKFLOW, declared_if, declared_needs, + evaluate_condition, job_blocks, release_context) +from tests.project.test_release_tags_the_run_sha_1634 import workflow_steps + +if TYPE_CHECKING: + from typing import Any, Optional + +#: The stand-in ``gh``: records its arguments, fails when ``GH_FAIL`` is set, and +#: otherwise applies its ``--jq`` filter to ``$REPLY`` as ``gh`` does, raw. +FAKE_GH = ('#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS"\n' + '[ -z "$GH_FAIL" ] || { echo "HTTP 404: Not Found" >&2; exit 1; }\n' + 'filter=""\nwhile [ $# -gt 0 ]; do [ "$1" = --jq ] && filter="$2"; shift; done\n' + 'exec jq -r "$filter" "$REPLY"\n') + +#: The jobs of Vendor Update 38650191283 and 38650782750, as "List jobs for a +#: workflow run" returned them, trimmed to the fields the filter reads. +NOT_COVERED = {'total_count': 3, 'jobs': [ + {'name': 'Verdict covers this commit', 'conclusion': 'success'}, + {'name': 'Vendor test gate', 'conclusion': 'skipped'}, + {'name': 'vendor-update', 'conclusion': 'skipped'}, +]} +COVERED = {'total_count': 3, 'jobs': [ + {'name': 'Verdict covers this commit', 'conclusion': 'success'}, + {'name': 'Vendor test gate', 'conclusion': 'skipped'}, + {'name': 'vendor-update', 'conclusion': 'success'}, +]} + +STEP = 'Read whether its vendor-update job ran' + + +class TestTheJob(unittest.TestCase): + """What ``vendor_update`` reads, with which token, and who reads it.""" + + @classmethod + def setUpClass(cls) -> None: + cls.jobs = job_blocks(WORKFLOW.read_text(encoding='utf-8')) + cls.steps = workflow_steps(WORKFLOW.read_text(encoding='utf-8')) + + def test_it_runs_only_for_a_vendor_update_that_succeeded(self) -> None: + condition = declared_if(self.jobs['vendor_update']) + for event_name, conclusion, expected in (('workflow_run', 'success', True), + ('workflow_run', 'skipped', False), + ('workflow_run', 'failure', False), + ('push', 'success', False)): + with self.subTest(event_name=event_name, conclusion=conclusion): + context = release_context(event_name=event_name, workflow_run_conclusion=conclusion) + self.assertEqual(evaluate_condition(condition, context), expected) + + def test_it_reads_the_triggering_runs_jobs_with_actions_read_only(self) -> None: + block = self.jobs['vendor_update'] + self.assertIn(' permissions:\n actions: read\n outputs:', block) + self.assertIn('updated: ${{ steps.updated.outputs.updated }}', block) + step = self.steps['vendor_update'][0] + self.assertEqual(step.get('name'), STEP) + self.assertEqual(step.get('env'), {'GH_TOKEN': '${{ github.token }}', + 'PCAPKIT_VENDOR_RUN_ID': '${{ github.event.workflow_run.id }}'}) + self.assertNotIn('${{', step['run']) + + def test_version_check_and_release_status_read_it(self) -> None: + self.assertEqual(declared_needs(self.jobs['version_check']), ['vendor_update']) + self.assertIn("needs.vendor_update.outputs.updated != 'false'", declared_if(self.jobs['version_check'])) + report = self.steps['release_status'][0] + self.assertEqual(report['env'].get('PCAPKIT_VENDOR_UPDATED'), '${{ needs.vendor_update.outputs.updated }}') + + def test_only_a_skipped_vendor_update_job_stops_version_check(self) -> None: + """Run 38650191283's ``'false'`` skips it; an unread ``null`` runs it, as before #1748.""" + condition = declared_if(self.jobs['version_check']) + for updated, expected in (('false', False), ('true', True), (NULL, True)): + with self.subTest(updated=updated): + self.assertEqual(evaluate_condition(condition, release_context(vendor_updated=updated)), + expected) + + +@unittest.skipIf(shutil.which('jq') is None or shutil.which('bash') is None, 'needs jq and bash on PATH') +class TestTheScript(unittest.TestCase): + """``vendor_update``'s step against the two real shapes and the ones it cannot read.""" + + @classmethod + def setUpClass(cls) -> None: + cls.script = workflow_steps(WORKFLOW.read_text(encoding='utf-8'))['vendor_update'][0]['run'] + + def setUp(self) -> None: + self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1748-')) + self.addCleanup(shutil.rmtree, self.tmp, True) + (self.tmp / 'bin').mkdir() + (self.tmp / 'bin' / 'gh').write_text(FAKE_GH, encoding='utf-8') + (self.tmp / 'bin' / 'gh').chmod(0o755) + (self.tmp / 'step.sh').write_text(self.script, encoding='utf-8') + + def run_step(self, reply: 'Optional[dict[str, Any]]', + fail: 'bool' = False) -> 'tuple[subprocess.CompletedProcess[str], str]': + (self.tmp / 'reply.json').write_text(json.dumps(reply), encoding='utf-8') + output = self.tmp / 'github-output' + output.write_text('', encoding='utf-8') + env = { + 'PATH': f'{self.tmp / "bin"}:/usr/bin:/bin:{shutil.which("jq").rsplit("/", 1)[0]}', # type: ignore[union-attr] + 'GITHUB_OUTPUT': str(output), 'GITHUB_REPOSITORY': 'JarryShaw/PyPCAPKit', + 'GH_TOKEN': 'token', 'PCAPKIT_VENDOR_RUN_ID': '38650191283', + 'REPLY': str(self.tmp / 'reply.json'), 'ARGS': str(self.tmp / 'args'), + 'GH_FAIL': '1' if fail else '', + } + result = subprocess.run(['bash', '-e', str(self.tmp / 'step.sh')], env=env, capture_output=True, + text=True, check=False, timeout=scale_timeout(30)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result, output.read_text(encoding='utf-8') + + def test_run_38650191283_updated_nothing(self) -> None: + _, written = self.run_step(NOT_COVERED) + self.assertEqual(written, 'updated=false\n') + self.assertEqual((self.tmp / 'args').read_text(encoding='utf-8').splitlines()[:3], + ['api', 'repos/JarryShaw/PyPCAPKit/actions/runs/38650191283/jobs', '--paginate']) + + def test_run_38650782750_updated(self) -> None: + self.assertEqual(self.run_step(COVERED)[1], 'updated=true\n') + + def test_what_it_cannot_read_leaves_updated_unset_and_warns(self) -> None: + """Unset runs ``version_check`` as before #1748: it can cost a run, never a release.""" + cases = { + 'the API refused': (COVERED, True), + 'no vendor-update job': ({'total_count': 0, 'jobs': []}, False), + 'vendor-update failed': ({'jobs': [{'name': 'vendor-update', 'conclusion': 'failure'}]}, False), + } + for name, (reply, fail) in sorted(cases.items()): + with self.subTest(case=name): + result, written = self.run_step(reply, fail=fail) + self.assertEqual(written, '') + self.assertIn('::warning title=Vendor Update not read (#1748)::', result.stdout) + self.assertIn('treats it as one that updated', result.stdout) + + +if __name__ == '__main__': + unittest.main() 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 bfe95968a..0c4176529 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -124,12 +124,19 @@ #: ``tag``'s two scripts, in the order the job runs them. TAG_STEPS = ('Create Tag', 'Reset Build Number') +#: #1748: ``version_check``'s step resolving the ``v*`` tag as :data:`CHECK_STEP` +#: does, and ``release_status``'s report, which names this run's commit. +OWNER_STEP = "Check which commit the version's tag names" +REPORT_STEP = 'Report why this run did or did not release anything' + #: Steps whose ``env:`` names ``PCAPKIT_RUN_SHA``, so the scan below cannot pass vacuously. RUN_SHA_STEPS = { + 'version_check': [OWNER_STEP], 'github': [CHECK_STEP, EXPLAIN_STEP], 'tag': list(TAG_STEPS), 'pypi': [CHECK_STEP], 'conda': [CHECK_STEP], + 'release_status': [REPORT_STEP], } #: Expressions naming another run's commit: ``workflow_run.head_sha`` is the @@ -1006,6 +1013,93 @@ def test_a_tag_pushed_after_the_checkout_is_still_seen(self) -> None: self.assertEqual(result.returncode, 0, result.stdout) +class TestTheOwnerStep(ScratchRepository): + """#1748: ``owner`` reads ``elsewhere`` exactly where :data:`CHECK_STEP` would refuse a tag that exists. + + So the gates that skip on ``PCAPKIT_RELEASED_ELSEWHERE`` skip only runs the + check would fail, and a missing tag -- which the check also refuses, but + which ``github`` creates -- is not "elsewhere". + + """ + + TAG = 'v1.5.0b9' + + def setUp(self) -> None: + super().setUp() + # #1644: no automatic maintenance to race the scratch directory's removal. + self.env.update({'GIT_CONFIG_COUNT': '3', 'GIT_CONFIG_KEY_0': 'maintenance.auto', 'GIT_CONFIG_VALUE_0': 'false', + 'GIT_CONFIG_KEY_1': 'gc.auto', 'GIT_CONFIG_VALUE_1': '0', + 'GIT_CONFIG_KEY_2': 'gc.autoDetach', 'GIT_CONFIG_VALUE_2': 'false'}) + self.output = self.tmp / 'github-output' + self.env['GITHUB_OUTPUT'] = str(self.output) + self.run_sha = self.commit(self.VERSION, '0') + self.later = self.commit(self.VERSION, '3') + self.push(self.later, 'refs/heads/main') + + def owner(self, run_sha: 'str') -> 'dict[str, str]': + self.output.write_text('', encoding='utf-8') + result = self.run_steps('version_check', (OWNER_STEP,), {'PCAPKIT_TAG': self.TAG, 'PCAPKIT_RUN_SHA': run_sha}) + self.assertEqual(result.returncode, 0, result.stdout) + return dict(line.split('=', 1) for line in self.output.read_text(encoding='utf-8').splitlines()) + + def refused(self, run_sha: 'str') -> 'bool': + result = self.run_steps('github', (CHECK_STEP,), {'PCAPKIT_TAGS': self.TAG, 'PCAPKIT_RUN_SHA': run_sha}) + return result.returncode != 0 + + def test_the_step_is_handed_the_tag_and_the_run_sha(self) -> None: + step = self.step('version_check', OWNER_STEP) + self.assertEqual(step.get('id'), 'owner') + self.assertEqual(step.get('env'), {'PCAPKIT_TAG': 'v${{ steps.get_version.outputs.PCAPKIT_VERSION }}', + 'PCAPKIT_RUN_SHA': RUN_SHA}) + ids = [each.get('id') for each in self.steps['version_check']] + self.assertEqual(ids.index('owner'), ids.index('check_conda') + 1) + + def test_a_tag_on_another_commit_is_elsewhere_and_refused(self) -> None: + """``v1.5.0b12``'s shape: tagged at ``2025da5e5``, read from a later commit.""" + self.push(self.run_sha, f'refs/tags/{self.TAG}') + self.checkout(self.later) + self.assertEqual(self.owner(self.later), + {'PCAPKIT_RELEASED_AT': self.run_sha, 'PCAPKIT_RELEASED_ELSEWHERE': 'true'}) + self.assertTrue(self.refused(self.later)) + + def test_a_tag_on_this_commit_is_not_elsewhere(self) -> None: + self.push(self.run_sha, f'refs/tags/{self.TAG}') + self.checkout(self.run_sha) + self.assertEqual(self.owner(self.run_sha), + {'PCAPKIT_RELEASED_AT': self.run_sha, 'PCAPKIT_RELEASED_ELSEWHERE': 'false'}) + self.assertFalse(self.refused(self.run_sha)) + + def test_no_tag_is_not_elsewhere(self) -> None: + """``github`` creates it, so the gates run.""" + self.checkout(self.later) + self.assertEqual(self.owner(self.later), {'PCAPKIT_RELEASED_AT': '', 'PCAPKIT_RELEASED_ELSEWHERE': 'false'}) + + def test_a_tag_pushed_after_the_checkout_is_fetched(self) -> None: + self.checkout(self.later) + self.push(self.run_sha, f'refs/tags/{self.TAG}') + self.assertEqual(self.owner(self.later)['PCAPKIT_RELEASED_ELSEWHERE'], 'true') + + def test_it_resolves_the_tag_as_the_check_does(self) -> None: + """The same fetch and the same ``rev-parse``, so the two cannot read the tag differently.""" + owner = self.step('version_check', OWNER_STEP)['run'] + check = self.step('github', CHECK_STEP)['run'] + for line in ('run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")"',): + self.assertIn(line, owner) + self.assertIn(line, check) + for line in ('git fetch --quiet --force origin "refs/tags/${tag}:refs/tags/${tag}" || true', + 'tagged="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)"'): + self.assertIn(line, check) + self.assertIn(line.replace('${tag}', '${PCAPKIT_TAG}'), owner) + + def test_both_outputs_reach_the_jobs(self) -> None: + block = job_blocks(WORKFLOW.read_text(encoding='utf-8'))['version_check'] + for name in ('PCAPKIT_RELEASED_AT', 'PCAPKIT_RELEASED_ELSEWHERE'): + with self.subTest(output=name): + self.assertIn(f'{name}: ${{{{ steps.owner.outputs.{name} }}}}', block) + self.assertEqual(self.step('release_status', REPORT_STEP)['env'][name], + f'${{{{ needs.version_check.outputs.{name} }}}}') + + RELEASED_STEP = 'Read the released Python versions' #: The tier file as #1677 added it, before the first weekly proposal, and its reader. FIRST_TIERS = ROOT / 'tests' / 'project' / 'python_versions_fixtures' / 'python-versions.toml' diff --git a/tests/project/test_unit_tests_queue.py b/tests/project/test_unit_tests_queue.py index 11f9e1945..b43e13202 100644 --- a/tests/project/test_unit_tests_queue.py +++ b/tests/project/test_unit_tests_queue.py @@ -603,8 +603,11 @@ def test_the_scan_is_not_blind(self) -> None: 'docs/source/contributing/releasing.rst': [ (None, 'push:', '- completed'), (None, 'concurrency:', 'cancel-in-progress: false'), - ('version_check', 'if: >-', 'vars.RELEASE_APP_BOT_ID) }}'), + ('vendor_update', ' vendor_update:', 'esac'), + ('version_check', 'if: >-', "needs.vendor_update.outputs.updated != 'false' }}"), ('version_check', '- name: Checkout code', 'fetch-depth: 0'), + ('version_check', "- name: Check which commit the version's tag names", + 'PCAPKIT_RELEASED_ELSEWHERE=${elsewhere}'), ('version_check', 'print(pcapkit.__version__)', None), ('unit-tests', ' unit-tests:', 'gate-only: true'), ('github', 'needs: [ unit-tests, version_check ]', None), @@ -626,11 +629,12 @@ def test_the_scan_is_not_blind(self) -> None: ('conda', 'PCAPKIT_RELEASE_TAG: v${{ needs.version_check.outputs.PCAPKIT_VERSION }}', None), ('conda', '- name: Upload package to GitHub Release', 'done <<< "$PCAPKIT_ASSETS"'), ('release_status', '[ -n "$PCAPKIT_RELEASE_APP_BOT_ID" ]', 'title=Nothing to release'), + ('release_status', 'if [ "${PCAPKIT_RELEASED_ELSEWHERE:-}" = "true" ]', 'is unfinished (#1634)::'), ], 'docs/source/contributing/workflows.rst': [ (None, 'workflow_run:', '- completed'), (None, 'concurrency:', 'cancel-in-progress: false'), - ('version_check', 'if: >-', 'vars.RELEASE_APP_BOT_ID) }}'), + ('version_check', 'if: >-', "needs.vendor_update.outputs.updated != 'false' }}"), ('unit-tests', 'needs: [ version_check ]', None), ('unit-tests', 'uses: ./.github/workflows/unit-tests.yml', None), ('github', 'environment: github-release', None),