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
449 changes: 392 additions & 57 deletions .github/workflows/create-release.yml

Large diffs are not rendered by default.

43 changes: 42 additions & 1 deletion .github/workflows/cron-conda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -535,11 +535,52 @@ jobs:
labels: ${{ needs.conda-update.outputs.PCAPKIT_CONDA_LABEL }}
force: true

- name: Upload package to GitHub Release
# #1634: the upload below never makes the release. ncipollo/release-action
# v1.21.0 creates a missing one, and its tag, at ``commit``
# (src/Action.ts:72-92), and publishes a draft, since an unset ``draft``
# is false on update (src/Inputs.ts:210-213). A bump made while Create
# Release waits on approval would then tag the pending version at main's
# tip, and Create Release's tag check would fail. Only the GitHub upload
# is skipped; the build is on anaconda.org regardless.
- name: Check the GitHub release exists
id: release
if: ${{ matrix.os == 'ubuntu-latest' }}
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PCAPKIT_RELEASE_TAG: v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}
run: |
errors="$(mktemp)"
status=0
draft="$(gh release view "$PCAPKIT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2> "$errors")" || status=$?
if [ "$status" -eq 0 ] && [ "$draft" = false ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$status" -eq 0 ] && [ "$draft" = true ]; then
why="is a draft, which the upload would publish"
elif [ "$(cat "$errors")" = 'release not found' ]; then
why="does not exist, and the upload would create it"
else
cat "$errors" >&2
echo "::error title=Could not read ${PCAPKIT_RELEASE_TAG}::gh release view exited ${status}."
exit 1
fi
echo "::notice title=No GitHub upload for ${PCAPKIT_RELEASE_TAG}::${PCAPKIT_RELEASE_TAG} ${why}, tagging main's tip. Create Release makes it, at the commit its run was created for (#1634); this build is on anaconda.org only."
echo "published=false" >> "$GITHUB_OUTPUT"

# ``commit`` is sent to Update a release as ``target_commitish``
# (src/Action.ts:107-120, src/Releases.ts:150-174), and
# ``github.ref_name`` is the default branch on both triggers here, so
# GitHub's workflow-file rule checks main, not the commit Create Release
# made the release at. The tag stays: the target is "Unused if the Git
# tag already exists" (REST docs, Update a release).
- name: Upload package to GitHub Release
if: ${{ matrix.os == 'ubuntu-latest' && steps.release.outputs.published == 'true' }}
uses: ncipollo/release-action@v1.21.0
with:
allowUpdates: true
artifacts: ${{ steps.conda_build.outputs.packages_csv }}
commit: ${{ github.ref_name }}
tag: "v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}"
token: "${{ secrets.GITHUB_TOKEN }}"
227 changes: 199 additions & 28 deletions docs/source/contributing/releasing.rst

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,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:379`` -- job ``unit-tests`` calls
* ``.github/workflows/create-release.yml:413`` -- 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 @@ -293,21 +293,25 @@ own graph. Create Release's first job, ``version_check`` -- ahead of the

.. code-block:: yaml

if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
if: >-
${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
!(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }}

(``create-release.yml:179``). 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
(``create-release.yml:210-212``). Its second half 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:369``) and an ``if:`` that
``needs: [ version_check ]`` (``create-release.yml:403``) 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 ]`` (``:397``), ``tag`` needs ``[ github, unit-tests,
version_check ]`` (``:498``), ``pypi`` needs ``[ github, unit-tests,
version_check ]`` (``:578``), and ``conda`` needs ``[ tag, github,
unit-tests, version_check ]`` (``:726``).
version_check ]`` (``:434``), ``tag`` needs ``[ github, unit-tests,
version_check ]`` (``:619``), ``pypi`` needs ``[ github, unit-tests,
version_check ]`` (``:784``), and ``conda`` needs ``[ tag, github,
unit-tests, version_check ]`` (``:997``).

.. mermaid::

Expand All @@ -333,7 +337,7 @@ unit-tests, version_check ]`` (``:726``).

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:498,578``) and of ``conda`` (``:726``), but ``github``
(``create-release.yml:619,784``) and of ``conda`` (``:997``), 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 @@ -612,10 +616,10 @@ Deployment ``environment:``
Four jobs, all in ``create-release.yml``, declare a deployment
``environment:`` and can therefore pause for an approval:

* ``github`` -- ``environment: github-release`` (``create-release.yml:394``)
* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:496``)
* ``pypi`` -- ``environment: pypi`` (``create-release.yml:573``)
* ``conda`` -- ``environment: anaconda`` (``create-release.yml:723``)
* ``github`` -- ``environment: github-release`` (``create-release.yml:431``)
* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:617``)
* ``pypi`` -- ``environment: pypi`` (``create-release.yml:779``)
* ``conda`` -- ``environment: anaconda`` (``create-release.yml:994``)

No other workflow in scope here declares one -- including
``deploy-pages.yml``, whose ``deploy-pages`` job pushes straight to the
Expand Down
2 changes: 2 additions & 0 deletions tests/project/test_python_matrix_tiers_1633.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ def test_no_ktlo_leg_can_fail_an_aggregated_job(self) -> None:
condition = str(step.get('if', ''))
if 'matrix.coverage' in condition or 'APT_CACHE_SAVE' in condition:
continue # only ever on a supported leg: the coverage one, the cache saver
if label == "Check the release tags name this run's commit":
continue # #1634: blocks every leg alike, pinned in its own test module
with self.subTest(workflow=name, job=job_id, step=label):
self.assertIn(step.get('continue-on-error'), (True, KTLO_GUARD))

Expand Down
97 changes: 86 additions & 11 deletions tests/project/test_release_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@

Be clear about how far that generalises, though, because it is easy to overstate.
:data:`PUBLISHING_MARKERS` is what decides "publishing", and it is a finite,
hand-maintained list of four substrings. So the rule generalises across job
hand-maintained list of five substrings. So the rule generalises across job
*identity* and not across publishing *mechanism*: a job uploading by some route
none of those four names -- ``twine upload``, ``gh release upload``, a fork of a
pinned action under another name, a ``curl`` straight at an index -- matches no
marker, and would pass this test while genuinely ungated.
:class:`TestMarkersStillMatch` only keeps the existing four from going blind; it
cannot invent a fifth. Adding a publishing route to this workflow therefore means
none of those five names -- ``twine upload``, a fork of a pinned action under
another name, a ``curl`` straight at an index -- matches no marker, and would
pass this test while genuinely ungated.
:class:`TestMarkersStillMatch` only keeps the existing five from going blind; it
cannot invent a sixth. Adding a publishing route to this workflow therefore means
adding its marker here, and that is a maintenance obligation rather than something
the test enforces.

Expand Down Expand Up @@ -266,6 +266,8 @@
'pypa/gh-action-pypi-publish': 'uploads a distribution to PyPI or TestPyPI',
'anaconda/actions/upload-package': 'uploads a package to anaconda.org',
'softprops/action-gh-release': 'creates a public GitHub Release, and the tag it names',
# ``pypi`` and ``conda`` attach their files this way since #1634.
'gh release upload': 'attaches a file to a public GitHub Release',
'git push': 'pushes a commit or a tag to the repository',
}

Expand Down Expand Up @@ -1002,14 +1004,24 @@ def _evaluate(node: 'tuple', context: 'dict[str, object]') -> 'object':
raise UnsupportedExpression(f'no evaluation rule for {node!r}') # pragma: no cover


def evaluate_expression(expression: 'str', context: 'dict[str, object]') -> 'object':
"""The value ``expression`` evaluates to in ``context``, not only its truthiness.

What a ``with:`` input receives, such as #1634's ``token:``, whose ``||``
returns an operand rather than a boolean.

"""
return _evaluate(parse_condition(expression), context)


def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool':
"""Whether a job carrying this ``if:`` would run in ``context``.

The answer an ``if:`` finally reduces to, which is the thing #962 asks to
be checked -- as opposed to the text that produced it.

"""
return _truthy(_evaluate(parse_condition(condition), context))
return _truthy(evaluate_expression(condition, context))


#: The four values a job's ``needs.<job>.result`` can take. The axis #962 is
Expand Down Expand Up @@ -1051,6 +1063,11 @@ def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool'
#: skipped gate admitted here would publish untested.
STRICT_PREDECESSORS = ('version_check', 'unit-tests')

#: ``github.actor_id`` of a person, and ``vars.RELEASE_APP_BOT_ID``, the release
#: App's bot (#1634). Made up: only whether the two are equal matters.
HUMAN_ACTOR_ID = '1000001'
RELEASE_APP_BOT_ID = '2000002'


def release_context(version_check: 'str' = 'success',
unit_tests: 'str' = 'success',
Expand All @@ -1062,7 +1079,9 @@ def release_context(version_check: 'str' = 'success',
cancelled: 'bool' = False,
outputs_present: 'bool' = True,
event_name: 'str' = 'workflow_run',
workflow_run_conclusion: 'str' = 'success') -> 'dict[str, object]':
workflow_run_conclusion: 'str' = 'success',
actor_id: 'str' = HUMAN_ACTOR_ID,
release_app_bot_id: 'str' = RELEASE_APP_BOT_ID) -> '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
Expand Down Expand Up @@ -1093,6 +1112,10 @@ def release_context(version_check: 'str' = 'success',
``v1.5.0`` is the other real value, from this workflow's ``push: tags:
['v*']`` trigger.

``actor_id`` is a person's by default, and the release App's bot id is set
(#1634). ``release_app_bot_id=''`` is the variable unset, which the
``vars`` docs give as "an empty string", not ``null``.

"""
outputs = {} # type: dict[str, object]
if outputs_present:
Expand All @@ -1115,8 +1138,10 @@ def release_context(version_check: 'str' = 'success',
'github': {
'ref_name': ref_name,
'event_name': event_name,
'actor_id': actor_id,
'event': {'workflow_run': {'conclusion': workflow_run_conclusion}},
},
'vars': {'RELEASE_APP_BOT_ID': release_app_bot_id},
STATUS: {'cancelled': cancelled},
}

Expand Down Expand Up @@ -2110,8 +2135,8 @@ def test_evidence_gated_jobs_survive_a_legitimately_skipped_predecessor(self) ->
Each dependency has to be checked for ``== 'success' || == 'skipped'``
explicitly, not merely ``!= 'failure'``: the latter still admits
``cancelled``, and a cancelled ``tag`` would send ``conda`` into its
``actions/checkout`` with ``ref: conda-<version>+0`` for a tag ``tag``
never pushed -- a checkout failure standing in for a clean skip,
check that ``conda-<version>+0`` names this run's commit (#1634), for a
tag ``tag`` never pushed -- a failed step standing in for a clean skip,
found on the same cross-review as the two blocking defects above.

"""
Expand Down Expand Up @@ -2350,9 +2375,20 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase):
#: is the workflow's outermost gate -- held by ``unit-tests`` until #1052
#: moved ``version_check`` ahead of it: a ``workflow_run`` trigger only
#: proceeds when the Vendor Update run that triggered it concluded
#: ``success``, and every other trigger proceeds unconditionally.
#: ``success``, and every other trigger proceeds -- #1634 -- unless it is
#: the ``v*`` push the release App's token makes, whose run would rerun
#: the gate and ask for a second approval. A person's push, and a
#: ``workflow_run`` whatever its actor, still run.
VERSION_CHECK_ROWS = [
('a v* tag push', release_context(event_name='push'), True),
("a v* tag push by the release App's bot",
release_context(event_name='push', actor_id=RELEASE_APP_BOT_ID), False),
('a v* tag push, no release App set up',
release_context(event_name='push', release_app_bot_id=''), True),
("Vendor Update succeeded, the actor the release App's bot",
release_context(actor_id=RELEASE_APP_BOT_ID), True),
("the App's bot, but Vendor Update found nothing to update",
release_context(actor_id=RELEASE_APP_BOT_ID, workflow_run_conclusion='skipped'), False),
('Vendor Update succeeded', release_context(), True),
('Vendor Update found nothing to update',
release_context(workflow_run_conclusion='skipped'), False),
Expand Down Expand Up @@ -3006,6 +3042,8 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]
env = {
'PCAPKIT_EVENT_NAME': 'workflow_run',
'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'success',
'PCAPKIT_ACTOR_ID': HUMAN_ACTOR_ID,
'PCAPKIT_RELEASE_APP_BOT_ID': RELEASE_APP_BOT_ID,
'PCAPKIT_UNIT_TESTS': 'success',
'PCAPKIT_VERSION_CHECK': 'success',
'PCAPKIT_GITHUB_JOB': 'success',
Expand Down Expand Up @@ -3042,6 +3080,33 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]
'::warning title=Release skipped',
0,
),
(
# #1634: the tag the release App's token made starts a push run
# that version_check skips by design, which is routine.
'release_app_pushed_the_tag',
{'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped',
'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '',
'PCAPKIT_ACTOR_ID': RELEASE_APP_BOT_ID},
'::notice title=Nothing to release',
0,
),
(
# A person's push that version_check skipped is still unexpected.
'a_persons_tag_push_was_skipped',
{'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped',
'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': ''},
'::warning title=Release skipped for an unexpected reason',
0,
),
(
# With no App set up, an empty actor id matches nothing.
'no_release_app_and_no_actor',
{'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped',
'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '',
'PCAPKIT_ACTOR_ID': '', 'PCAPKIT_RELEASE_APP_BOT_ID': ''},
'::warning title=Release skipped for an unexpected reason',
0,
),
(
'version_check_failed',
{
Expand Down Expand Up @@ -3143,6 +3208,16 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]
),
]

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'))
result = self._run(overrides)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [
"::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).",
])

def test_each_scenario_takes_the_right_branch(self) -> None:
for name, overrides, expected_marker, expected_rc in self.VECTORS:
with self.subTest(scenario=name):
Expand Down
Loading
Loading