Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 159 additions & 6 deletions .github/workflows/create-release.yml

Large diffs are not rendered by default.

64 changes: 39 additions & 25 deletions docs/source/contributing/releasing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -124,6 +124,13 @@ conclude ``success`` is keyed on its own ``github.run_id`` instead
(`#1705 <https://github.com/JarryShaw/PyPCAPKit/issues/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 <https://github.com/JarryShaw/PyPCAPKit/issues/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
----------------------
Expand All @@ -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-<version>+0`` at that commit (``:722-749``) before it
* ``tag`` cuts ``conda-<version>+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 <https://github.com/JarryShaw/PyPCAPKit/issues/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 <https://github.com/JarryShaw/PyPCAPKit/issues/1640>`__): the action
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<version>`` the refused run left:
Expand Down Expand Up @@ -432,8 +446,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``,
removed on `#887 <https://github.com/JarryShaw/PyPCAPKit/issues/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
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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-<version>+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
Expand Down
55 changes: 35 additions & 20 deletions docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,10 @@ the verdict for 9083163f5 (`#1643 <https://github.com/JarryShaw/PyPCAPKit/issues
So Vendor Update, Conda Update and GitHub Pages each run a ``verdict`` job first,
and act only when the tip is the verified commit, or that commit plus bot commits
to ``conda/build`` and ``conda/requirements.txt`` alone; otherwise they skip, and
the tip's own Unit Tests run raises them again. The rule and its evidence are in
the tip's own Unit Tests run raises them again. Vendor Update's run still
concludes ``success`` then, since ``verdict`` ran, so Create Release reads its
``vendor-update`` job to tell it from a real one
(`#1748 <https://github.com/JarryShaw/PyPCAPKit/issues/1748>`__). 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
Expand All @@ -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``.
Expand Down Expand Up @@ -323,35 +326,43 @@ The Skip Cascade (`#888 <https://github.com/JarryShaw/PyPCAPKit/issues/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 <https://github.com/JarryShaw/PyPCAPKit/issues/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<br/>conclusion != success"]
WR["Vendor Update completed<br/>conclusion != success,<br/>or vendor-update skipped"]
VC["version_check<br/>SKIPPED"]
UTX["unit-tests (uses: unit-tests.yml)<br/>SKIPPED"]
GH["github (GitHub Release)<br/>SKIPPED"]
Expand All @@ -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.
Expand Down Expand Up @@ -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``
-----------------------
Expand Down Expand Up @@ -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`_)

Expand Down
8 changes: 5 additions & 3 deletions tests/project/test_python_versions_update_1660.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'], {})
Expand Down
Loading
Loading