Skip to content

Commit eb3ddaa

Browse files
committed
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, 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
1 parent d651060 commit eb3ddaa

9 files changed

Lines changed: 731 additions & 79 deletions

‎.github/workflows/create-release.yml‎

Lines changed: 159 additions & 6 deletions
Large diffs are not rendered by default.

‎docs/source/contributing/releasing.rst‎

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s
5050
``tests/project/test_bump_version.py``'s
5151
``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version``
5252
(``:494-514``) asserts the two agree, and ``create-release.yml``'s
53-
``unit-tests`` job (``:467-481``) calls ``unit-tests.yml`` with
53+
``unit-tests`` job (``:579-595``) calls ``unit-tests.yml`` with
5454
``gate-only: true``, which runs the **full** suite rather than the tiered
5555
subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1776-1846``).
5656
That gate runs whenever a release will -- a moved ``__version__`` is exactly
5757
what makes ``version_check``'s evidence read "not yet published" -- and every
58-
publishing job requires it to have succeeded (``:500,685,850,1085``), so a
58+
publishing job requires it to have succeeded (``:619,806,972,1208``), so a
5959
citation left behind fails the gate well before any approval is requested.
6060
Either run the script, or move both fields by hand in the same commit.
6161

@@ -66,7 +66,7 @@ process: the ``changelog`` job (``unit-tests.yml:1624-1643``) fails outright if
6666
``CHANGELOG.md`` has drifted from its source entry under
6767
:file:`docs/source/changelog/`, and the release-body step warns, without
6868
failing, if that entry's heading still reads "unreleased"
69-
(``create-release.yml:554-555``). Both enforce what the changelog convention
69+
(``create-release.yml:675-676``). Both enforce what the changelog convention
7070
already asks for.
7171

7272
Two Ways to Start a Release
@@ -79,12 +79,12 @@ neither is a workaround for the other:
7979
``push: tags: ['v*']``
8080
Pushing a tag matching ``v*`` yourself. This chooses **which commit** to
8181
release, not which version: ``version_check``'s checkout
82-
(``:216-220``) pins ``ref: ${{ github.sha }}``, which on this trigger is
82+
(``:298-302``) pins ``ref: ${{ github.sha }}``, which on this trigger is
8383
the commit the pushed tag points at, and reads ``pcapkit.__version__`` out
84-
of *that* tree (``:245``). Every downstream tag and release name is built
84+
of *that* tree (``:327``). Every downstream tag and release name is built
8585
from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}``
86-
is ``github``'s ``tag_name`` at ``:596`` and the release ``pypi`` and
87-
``conda`` upload to at ``:992`` and ``:1338``, always from
86+
is ``github``'s ``tag_name`` at ``:717`` and the release ``pypi`` and
87+
``conda`` upload to at ``:1115`` and ``:1462``, always from
8888
``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version
8989
actually released is whatever ``__version__`` says on the tagged commit,
9090
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
124124
(`#1705 <https://github.com/JarryShaw/PyPCAPKit/issues/1705>`__). It releases
125125
nothing, so it neither waits behind a release nor displaces a run waiting
126126
there; only a newer run that may release, reading ``main``'s newer tip, can.
127+
A Vendor Update that concluded ``success`` with ``vendor-update`` skipped --
128+
its Unit Tests verdict was for another commit -- releases nothing either since
129+
`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/1748>`__: the
130+
``vendor_update`` job reads that run's jobs (``:230-264``), and
131+
``version_check`` skips. The group cannot see that, so such a run still joins
132+
the ref's group; if it displaces a pending run, that release waits for the
133+
next Vendor Update on ``main``.
127134

128135
One Commit per Release
129136
----------------------
@@ -137,19 +144,26 @@ days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and
137144
the default branch, and ``tag`` pulled ``main`` before tagging.
138145

139146
* On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes
140-
``target_commitish`` ``github.sha`` (``:609``); on a ``v*`` push the tag
147+
``target_commitish`` ``github.sha`` (``:730``); on a ``v*`` push the tag
141148
already exists and it passes none. Either way it then checks that the tag
142149
names that commit.
143-
* ``tag`` cuts ``conda-<version>+0`` at that commit (``:722-749``) before it
150+
* ``tag`` cuts ``conda-<version>+0`` at that commit (``:844-871``) before it
144151
reads ``main`` at all; the :file:`conda/build` reset is a separate commit on
145-
top of ``main`` (``:771-824``), described under `Precautions`_.
152+
top of ``main`` (``:893-946``), described under `Precautions`_.
146153
* ``pypi`` and ``conda`` check out that commit and, before building, check
147154
that every tag they publish under names it, as ``tag`` does for the ``v*``
148-
tag. A run created for a *later* commit of the same version therefore fails
149-
with an ``::error`` instead of publishing a second tree under that version;
150-
a partial release is finished by re-running the run that tagged it.
155+
tag. A run created for a *later* commit of the same version therefore never
156+
publishes a second tree under that version. ``version_check`` resolves the
157+
``v*`` tag the same way first (``:534-552``), and off a ``v*`` ref a tag
158+
naming another commit skips the gate and all four publishers
159+
(`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/1748>`__):
160+
``Release status`` posts a ``::notice`` if that release is complete, or a
161+
``::warning`` naming both commits and what is missing (``:1607-1617``), and
162+
the run stays green. A ``v*`` push of such a version still fails with the
163+
check's ``::error``. A partial release is finished by re-running the run that
164+
tagged it.
151165
* ``pypi`` and ``conda`` attach their files with ``gh release upload``
152-
(``:985-1022``, ``:1333-1368``), not ``action-gh-release``, which calls Update
166+
(``:1108-1145``, ``:1457-1492``), not ``action-gh-release``, which calls Update
153167
a release before every upload (3.0.2's ``src/github.ts:631-683``). A file
154168
the release already has is left alone
155169
(`#1640 <https://github.com/JarryShaw/PyPCAPKit/issues/1640>`__): the action
@@ -243,11 +257,11 @@ The owner's choice on `#1634
243257
the release with a GitHub App installation token, which Create a release
244258
accepts for a target that changes a workflow file when it has the "Workflows"
245259
repository permission (write). "Mint the release App's token"
246-
(``create-release.yml:569-577``) runs
260+
(``create-release.yml:690-698``) runs
247261
``actions/create-github-app-token@v3`` with ``permission-contents: write`` and
248262
``permission-workflows: write`` and no ``owner`` or ``repositories``, so the
249263
token is "scoped to only the current repository" and revoked in the action's
250-
``post`` step (v3.3.0's README). Only Create Release spends it (``:613``).
264+
``post`` step (v3.3.0's README). Only Create Release spends it (``:734``).
251265
While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty
252266
string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the
253267
workflow keeps working, refusals included, until the App exists. A PAT would
@@ -275,15 +289,15 @@ One-time setup:
275289
Unlike ``GITHUB_TOKEN``'s, the App's tag starts a tag-push run. It would rerun
276290
the release test gate, ask for a second approval and race the first run's
277291
uploads from another concurrency group, so ``version_check`` skips a ``push``
278-
whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:210-212``), and
292+
whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:290-294``), and
279293
``release_status`` reports it with ``::notice title=Nothing to release``
280-
(``:1440-1442``). A tag pushed by a person runs as before. Whether a tag made
294+
(``:1569-1571``). A tag pushed by a person runs as before. Whether a tag made
281295
through the Releases API fires ``push``, and with the bot as actor, is
282296
unmeasured; if not, the guard never matches and the cost is one approval to
283297
reject.
284298

285299
A refused Create Release (404, or 403 "Resource not accessible by
286-
integration") is explained by "Explain a refused release tag" (``:646-658``)
300+
integration") is explained by "Explain a refused release tag" (``:767-779``)
287301
according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above;
288302
with the App's, it points at the App's permissions and installation. For a
289303
final release, first delete any draft ``v<version>`` the refused run left:
@@ -432,8 +446,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``,
432446
removed on `#887 <https://github.com/JarryShaw/PyPCAPKit/issues/887>`__.
433447
What still makes one approval mean *the whole release* is the ``needs:``
434448
graph: ``tag`` and ``pypi`` both depend on ``github``
435-
(``.github/workflows/create-release.yml:685,850``), and ``conda`` depends on
436-
``tag`` and ``github`` (``:1085``) -- so nothing downstream of ``github`` can
449+
(``.github/workflows/create-release.yml:806,972``), and ``conda`` depends on
450+
``tag`` and ``github`` (``:1208``) -- so nothing downstream of ``github`` can
437451
start before it is approved, and rejecting it leaves nothing tagged and
438452
nothing published. Before this change ``tag`` depended on ``version_check``
439453
alone, so removing its reviewer without moving this dependency would have let
@@ -457,8 +471,8 @@ Precautions
457471
*their own* artefact is missing rather than whether the ``v*`` tag exists, so an
458472
incomplete release runs the jobs that did not finish instead of skipping them.
459473
A half-finished release self-heals on re-running the run that tagged it
460-
-- see `Recovery`_ below; a run created for a later commit refuses instead
461-
(`One Commit per Release`_).
474+
-- see `Recovery`_ below; a run created for a later commit releases nothing
475+
instead, with a warning that names what is missing (`One Commit per Release`_).
462476
``release_status`` is the other half: it runs unconditionally and reports,
463477
with a ``::notice``, a ``::warning`` or a failing ``::error``, why a run
464478
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
480494

481495
**``environment: pypi`` stays even though its reviewer is gone.** ``pypi``
482496
publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at
483-
``:845``, ``id-token: write`` at ``:849``), and PyPI's trusted-publisher
497+
``:967``, ``id-token: write`` at ``:971``), and PyPI's trusted-publisher
484498
configuration can be scoped to a GitHub Actions environment name. If this
485499
project's publisher on PyPI is scoped that way, removing the ``pypi`` name
486500
-- 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.
491505
**``conda-tag`` writes to ``main``, so a release is not read-only on the
492506
branch.** After cutting ``conda-<version>+0`` at the run's commit, the
493507
``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of
494-
``main`` (``.github/workflows/create-release.yml:771-824``) -- a plain
508+
``main`` (``.github/workflows/create-release.yml:893-946``) -- a plain
495509
fast-forward push, never a rewrite -- so the next Conda Update bump of that
496510
version is ``+1``. There is none when the run's commit is not on ``main``, nor
497511
when ``main``'s tip already says ``0`` (the usual case, since

‎docs/source/contributing/workflows.rst‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,10 @@ the verdict for 9083163f5 (`#1643 <https://github.com/JarryShaw/PyPCAPKit/issues
256256
So Vendor Update, Conda Update and GitHub Pages each run a ``verdict`` job first,
257257
and act only when the tip is the verified commit, or that commit plus bot commits
258258
to ``conda/build`` and ``conda/requirements.txt`` alone; otherwise they skip, and
259-
the tip's own Unit Tests run raises them again. The rule and its evidence are in
259+
the tip's own Unit Tests run raises them again. Vendor Update's run still
260+
concludes ``success`` then, since ``verdict`` ran, so Create Release reads its
261+
``vendor-update`` job to tell it from a real one
262+
(`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/1748>`__). The rule and its evidence are in
260263
``util/verdict_covers_checkout.py``. Conda Update also writes after the
261264
checkout, so it applies the same rule a second time: its push rebases onto
262265
``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
283286
starting an independent run after the caller finishes. All four calls in this
284287
repository target the same reusable workflow, with the same input:
285288

286-
* ``.github/workflows/create-release.yml:479`` -- job ``unit-tests`` calls
289+
* ``.github/workflows/create-release.yml:593`` -- job ``unit-tests`` calls
287290
``./.github/workflows/unit-tests.yml`` with ``gate-only: true``.
288291
* ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls
289292
``./.github/workflows/unit-tests.yml`` with ``gate-only: true``.
@@ -323,35 +326,43 @@ The Skip Cascade (`#888 <https://github.com/JarryShaw/PyPCAPKit/issues/888>`__)
323326
-------------------------------------------------------------------------------
324327

325328
The two relationships above compose into a failure mode worth seeing on its
326-
own graph. Create Release's first job, ``version_check`` -- ahead of the
327-
``uses:`` call above -- carries:
329+
own graph. Create Release's ``version_check`` -- ahead of the ``uses:`` call
330+
above -- carries:
328331

329332
.. code-block:: yaml
330333
331334
if: >-
332-
${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
333-
!(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }}
335+
${{ !cancelled() &&
336+
(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
337+
!(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) &&
338+
needs.vendor_update.outputs.updated != 'false' }}
334339
335-
(``create-release.yml:210-212``). Its second half skips the tag-push run that
340+
(``create-release.yml:290-294``). Its third clause skips the tag-push run that
336341
the release App's own tag starts (:doc:`releasing`, "The Release App"). On the
337342
``workflow_run`` path, this evaluates **false**, and the job is skipped
338343
outright, whenever the upstream Vendor Update run's own conclusion was
339344
anything other than ``success`` -- including
340345
``skipped``, which is exactly what Vendor Update's *own* jobs report when
341346
*their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s
342-
``vendor-update`` job). ``unit-tests`` then depends on that job with
343-
``needs: [ version_check ]`` (``create-release.yml:469``) and an ``if:`` that
347+
``vendor-update`` job). Since
348+
`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/1748>`__ it is also false
349+
for a Vendor Update that concluded ``success`` with ``vendor-update`` skipped --
350+
its ``verdict`` job found the Unit Tests verdict was for another commit -- which
351+
the ``vendor_update`` job reads from that run's jobs first, as run 38650191283
352+
was. ``!cancelled()`` keeps a skipped ``vendor_update``, on a push, from
353+
skipping it. ``unit-tests`` then depends on that job with
354+
``needs: [ version_check ]`` (``create-release.yml:581``) and an ``if:`` that
344355
calls no status-check function, so a skipped dependency skips it too -- and
345356
every job past it is gated the same way: ``github`` needs ``[ unit-tests,
346-
version_check ]`` (``:500``), ``tag`` needs ``[ github, unit-tests,
347-
version_check ]`` (``:685``), ``pypi`` needs ``[ github, unit-tests,
348-
version_check ]`` (``:850``), and ``conda`` needs ``[ tag, github,
349-
unit-tests, version_check ]`` (``:1085``).
357+
version_check ]`` (``:619``), ``tag`` needs ``[ github, unit-tests,
358+
version_check ]`` (``:806``), ``pypi`` needs ``[ github, unit-tests,
359+
version_check ]`` (``:972``), and ``conda`` needs ``[ tag, github,
360+
unit-tests, version_check ]`` (``:1208``).
350361

351362
.. mermaid::
352363

353364
flowchart TD
354-
WR["Vendor Update completed<br/>conclusion != success"]
365+
WR["Vendor Update completed<br/>conclusion != success,<br/>or vendor-update skipped"]
355366
VC["version_check<br/>SKIPPED"]
356367
UTX["unit-tests (uses: unit-tests.yml)<br/>SKIPPED"]
357368
GH["github (GitHub Release)<br/>SKIPPED"]
@@ -372,7 +383,7 @@ unit-tests, version_check ]`` (``:1085``).
372383

373384
This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and
374385
``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi``
375-
(``create-release.yml:685,850``) and of ``conda`` (``:1085``), but ``github``
386+
(``create-release.yml:806,972``) and of ``conda`` (``:1208``), but ``github``
376387
already implies those edges, since it depends on both itself. Drawing them
377388
would add lines without changing which job can start before which, or which
378389
job a skip in ``version_check`` reaches.
@@ -400,7 +411,11 @@ Such a run is also kept out of the release's concurrency group
400411
its own ``github.run_id`` rather than on the ref, so it cannot cancel a run
401412
waiting there with release work. Keyed on the ref, run 38499295798 joined at
402413
02:06:32Z on 2026-10-14, and the pending 38499150441, whose Vendor Update had
403-
succeeded, was cancelled a second later.
414+
succeeded, was cancelled a second later. A run whose Vendor Update concluded
415+
``success`` with ``vendor-update`` skipped (#1748) is still keyed on the ref:
416+
the group reads only the event, which does not say which jobs ran. It can still
417+
displace a run pending there, and now releases nothing in its place, so that
418+
release waits for the next Vendor Update on ``main``.
404419

405420
``pull_request_target``
406421
-----------------------
@@ -814,10 +829,10 @@ Deployment ``environment:``
814829
Five jobs declare a deployment ``environment:`` and can therefore pause for an
815830
approval, four in ``create-release.yml`` and one in ``cron-python-versions.yml``:
816831

817-
* ``github`` -- ``environment: github-release`` (``create-release.yml:497``)
818-
* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:683``)
819-
* ``pypi`` -- ``environment: pypi`` (``create-release.yml:845``)
820-
* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1082``)
832+
* ``github`` -- ``environment: github-release`` (``create-release.yml:616``)
833+
* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:804``)
834+
* ``pypi`` -- ``environment: pypi`` (``create-release.yml:967``)
835+
* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1205``)
821836
* ``pr`` -- ``environment: github-release``, for the release App's key (see
822837
`Python Versions`_)
823838

‎tests/project/test_python_versions_update_1660.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,10 +1252,12 @@ def test_version_check_treats_its_merge_like_any_other(self) -> None:
12521252
self.assertEqual(set(on), {'push', 'workflow_run'})
12531253
self.assertEqual(on['push'], {'tags': ['v*']})
12541254
self.assertEqual(on['workflow_run']['workflows'], ['Vendor Update'])
1255+
# #1748 added the triggering Vendor Update's own reading, which names no branch either.
12551256
self.assertEqual(' '.join(spec['jobs']['version_check']['if'].split()),
1256-
"${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == "
1257-
"'success') && !(github.event_name == 'push' && github.actor_id == "
1258-
"vars.RELEASE_APP_BOT_ID) }}")
1257+
"${{ !cancelled() && (github.event_name != 'workflow_run' || "
1258+
"github.event.workflow_run.conclusion == 'success') && !(github.event_name == 'push' "
1259+
"&& github.actor_id == vars.RELEASE_APP_BOT_ID) && "
1260+
"needs.vendor_update.outputs.updated != 'false' }}")
12591261

12601262
def test_permissions_are_nothing_by_default_and_little_per_job(self) -> None:
12611263
self.assertEqual(self.spec['permissions'], {})

0 commit comments

Comments
 (0)