From 0b5f60dd4c5a31986619cfe61e578f9fabadbc63 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 22 Oct 2026 22:22:10 -0400 Subject: [PATCH] ci(release): run the release status and vendor-update reads from util/ scripts (#1967) Step 2 of #1967, second batch for create-release.yml: the last two inline shell steps move into stdlib-only scripts, with each step's env:, if:, needs:, outputs and timeout-minutes unchanged. * util/release_status.py: "Report why this run did or did not release anything" in release_status. * util/release_vendor_update.py: "Read whether its vendor-update job ran" in vendor_update. * Both jobs gain contents: read and a checkout of util/ alone at github.sha, without credentials, ahead of the script step. * workflows.rst lists both scripts. tests/project/test_release_status_scripts_1967.py runs each former shell, kept verbatim, and its script on the same cases, and requires the same status, output, $GITHUB_OUTPUT, step summary and gh calls; the case tables reach every branch of each shell. The tests that ran or pinned the shell now run or read the scripts, and find the steps by name. --- .github/workflows/create-release.yml | 184 +----- docs/source/contributing/workflows.rst | 9 + tests/project/test_actions_helper_2047.py | 7 +- tests/project/test_discussion_report_2030.py | 5 +- .../project/test_python_matrix_tiers_1633.py | 19 +- tests/project/test_release_gates.py | 41 +- ...st_release_notice_states_no_totals_1849.py | 11 +- ...st_release_reads_the_vendor_update_1748.py | 39 +- .../test_release_status_scripts_1967.py | 610 ++++++++++++++++++ .../test_release_tags_the_run_sha_1634.py | 17 +- util/release_status.py | 234 +++++++ util/release_vendor_update.py | 138 ++++ 12 files changed, 1109 insertions(+), 205 deletions(-) create mode 100644 tests/project/test_release_status_scripts_1967.py create mode 100644 util/release_status.py create mode 100644 util/release_vendor_update.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 105294ef3c..491bc1354d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -136,6 +136,10 @@ jobs: # in that run's place, 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. + # + # #1967: util/release_vendor_update.py holds the step's shell, so the job + # checks out util/ alone, for it and util/_actions.py, which is all + # ``contents: read`` is for. vendor_update: name: Triggering Vendor Update runs-on: ubuntu-latest @@ -143,34 +147,23 @@ jobs: timeout-minutes: 5 permissions: actions: read + contents: read outputs: updated: ${{ steps.updated.outputs.updated }} steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + sparse-checkout: util + persist-credentials: false + - 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 + run: python3 util/release_vendor_update.py version_check: name: Check Version @@ -1199,15 +1192,25 @@ jobs: # chosen over ``always()`` so that cancelling the run cancels this report too # rather than leaving it to describe a run it cannot fully see. # - # No ``environment:`` and ``permissions: {}``: it only annotates, from the - # ``needs:`` context and the trigger event. + # No ``environment:``: it only annotates, from the ``needs:`` context and the + # trigger event. #1967: util/release_status.py holds the step's shell, so the + # job checks out util/ alone, for it and util/_actions.py, which is all + # ``contents: read`` is for. release_status: name: Release status runs-on: ubuntu-latest needs: [ vendor_update, unit-tests, version_check, github, tag, pypi, conda ] if: ${{ !cancelled() }} - permissions: {} + permissions: + contents: read steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + sparse-checkout: util + persist-credentials: false + - name: Report why this run did or did not release anything env: PCAPKIT_EVENT_NAME: ${{ github.event_name }} @@ -1231,137 +1234,4 @@ jobs: 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: | - set -euo pipefail - - # #1633: pypi's KTLO legs reach neither needs.pypi.result - # (continue-on-error) nor PCAPKIT_PYPI_COMPLETE's count, so each is - # only reported here, as a notice, from its own job output. - ktlo_310="${PCAPKIT_PYPI_KTLO_310:-}" - ktlo_pypy310="${PCAPKIT_PYPI_KTLO_PYPY310:-}" - if [ -n "${ktlo_310}${ktlo_pypy310}" ]; then - echo "::notice title=KTLO legs (non-blocking)::pypi 3.10=${ktlo_310:-not run} pypy3.10=${ktlo_pypy310:-not run}. End-of-life Python, built and published but never blocking (#1633); neither result changes this run's verdict." - fi - - # Case 1: version_check never ran. On `workflow_run`, Vendor Update did - # not conclude `success`: `skipped`, nothing to update, gets a quiet - # notice; anything else a warning, as Vendor Update needs attention -- - # but no tag exists to strand, so it is not #888. On a push it is quiet - # only for the release App's own tag (#1634); an unset bot id never - # matches. #1748: a `success` that never ran 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 - echo "::warning title=Release skipped for an unexpected reason::version_check reported 'skipped' with event_name='${PCAPKIT_EVENT_NAME}', which is not the workflow_run non-success case this job otherwise expects. Check the version_check job's own if: condition." - fi - exit 0 - fi - - # version_check ran and did not succeed: a workflow failure, not #888. - if [ "$PCAPKIT_VERSION_CHECK" != "success" ]; then - echo "::warning title=Release blocked before it could start::version_check did not succeed (result: '${PCAPKIT_VERSION_CHECK}'), so neither the release test gate nor any release job ran. This is a workflow failure, not #888." - exit 0 - fi - - # The gate did not pass, so every release job skipped: a test failure, - # not #888, and already red in this run's checks. - if [ "$PCAPKIT_UNIT_TESTS" = "failure" ] || [ "$PCAPKIT_UNIT_TESTS" = "cancelled" ]; then - echo "::warning title=Release blocked by the test gate::the release test gate concluded '${PCAPKIT_UNIT_TESTS}', so no release job ran for ${PCAPKIT_VERSION}. This is a test failure, not #888." - exit 0 - fi - - # #1748: tagged at another commit, with the gate and every publisher - # skipped for it, which happens only off a v* ref. That release is that - # commit's (#1634): a notice if complete, else a warning naming what it - # lacks, both green. A publisher that ran 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 the evidence outputs are meaningful. A skipped gate (#1052) - # falls through on purpose: it skips only when every evidence output - # reads 'true' off a v* ref, reported as "Nothing to release" below; if - # its predicate drifts, every publisher skips behind it and this - # reaches the loud #888 branch instead. - # - # Reconcile each job against its OWN evidence, not against each other: - # a self-heal mixes jobs that skipped because their artefact existed - # with jobs that ran, and demanding uniformity would read that mix as - # #888 on the run that finished the release. Reconciled = succeeded, or - # skipped with its own evidence saying done. - reconciled_github=false - [ "$PCAPKIT_GITHUB_JOB" = "success" ] && reconciled_github=true - [ "$PCAPKIT_GITHUB_JOB" = "skipped" ] && [ "$PCAPKIT_TAG_EXISTS" = "true" ] && reconciled_github=true - - reconciled_tag=false - [ "$PCAPKIT_TAG_JOB" = "success" ] && reconciled_tag=true - [ "$PCAPKIT_TAG_JOB" = "skipped" ] && [ "$PCAPKIT_CONDA_TAG_EXISTS" = "true" ] && reconciled_tag=true - - reconciled_pypi=false - [ "$PCAPKIT_PYPI_JOB" = "success" ] && reconciled_pypi=true - [ "$PCAPKIT_PYPI_JOB" = "skipped" ] && [ "$PCAPKIT_PYPI_COMPLETE" = "true" ] && reconciled_pypi=true - - reconciled_conda=false - [ "$PCAPKIT_CONDA_JOB" = "success" ] && reconciled_conda=true - [ "$PCAPKIT_CONDA_JOB" = "skipped" ] && [ "$PCAPKIT_CONDA_COMPLETE" = "true" ] && reconciled_conda=true - - all_reconciled=false - if [ "$reconciled_github" = "true" ] && [ "$reconciled_tag" = "true" ] \ - && [ "$reconciled_pypi" = "true" ] && [ "$reconciled_conda" = "true" ]; then - all_reconciled=true - fi - - all_skipped=true - any_broken=false - for result in "$PCAPKIT_GITHUB_JOB" "$PCAPKIT_TAG_JOB" "$PCAPKIT_PYPI_JOB" "$PCAPKIT_CONDA_JOB"; do - [ "$result" = "skipped" ] || all_skipped=false - if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then - any_broken=true - fi - done - - # Case 2: every target reconciled by skipping -- the usual steady state. - if [ "$all_reconciled" = "true" ] && [ "$all_skipped" = "true" ]; then - echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag and every PyPI and Anaconda file version_check expects are present. This run correctly released nothing." - exit 0 - fi - - # Every target reconciled and at least one ran: a fresh release or a - # self-heal, both quiet. - if [ "$all_reconciled" = "true" ]; then - echo "::notice title=Release complete::pypcapkit ${PCAPKIT_VERSION} is now fully released -- github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB}. A 'skipped' entry there means that target's own artefact already existed before this run, not that anything was missed." - exit 0 - fi - - if [ "$any_broken" = "true" ]; then - echo "::warning title=Release did not complete::github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB} for ${PCAPKIT_VERSION}. At least one release job failed or was cancelled outright -- see its own log; this run's checks already show that failure in red." - exit 0 - fi - - # Case 3, the #888 shape: a target skipped despite its own evidence - # saying incomplete, with nothing failed or cancelled to explain it. - # The evidence-based `if:`s should make this unreachable, so reaching - # it means that assumption broke: loud, and never green. - echo "::error title=Stranded partial release (#888)::${PCAPKIT_VERSION} is only partially published (tag exists=${PCAPKIT_TAG_EXISTS}, conda tag exists=${PCAPKIT_CONDA_TAG_EXISTS}, pypi complete=${PCAPKIT_PYPI_COMPLETE}, conda complete=${PCAPKIT_CONDA_COMPLETE}) but github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB} -- at least one target skipped despite its own evidence saying it is not done. Re-running this workflow should let the incomplete jobs self-heal; if this recurs on the next run too, the evidence checks in version_check are the first thing to check." - exit 1 + run: python3 util/release_status.py diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 7f2d3f9b3a..8459421e37 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1267,6 +1267,15 @@ does; each has tests under :file:`tests/project/` that run it. * - :file:`util/release_upload.py` - ``Upload package to GitHub Release`` in ``create-release.yml``'s ``pypi`` and ``conda`` (`#1640 `__). + * - :file:`util/release_status.py` + - ``Report why this run did or did not release anything`` in + ``create-release.yml``'s ``release_status``, on the image's ``python3`` + after a checkout of :file:`util/` alone (`#888 + `__). + * - :file:`util/release_vendor_update.py` + - ``Read whether its vendor-update job ran`` in ``create-release.yml``'s + ``vendor_update``, on the image's ``python3`` after a checkout of + :file:`util/` alone (`#1748 `__). The summaries run on the image's ``/usr/bin/python3`` rather than the leg's interpreter, which may have failed to set up or be too broken to run anything, so diff --git a/tests/project/test_actions_helper_2047.py b/tests/project/test_actions_helper_2047.py index 10b57c9c2c..60ad17b08e 100644 --- a/tests/project/test_actions_helper_2047.py +++ b/tests/project/test_actions_helper_2047.py @@ -41,8 +41,9 @@ HELPER = ROOT / 'util' / '_actions.py' #: The scripts that import the helper. SCRIPTS = ('apt_packages', 'check_suite_report', 'ci_timing', 'classify_changes', 'codeql_report', - 'coverage_report', 'leg_summary', 'pip_install', 'regenerate_samples', 'release_tags', 'release_upload', - 'update_python_versions', 'vendor_code_changed', 'vendor_test_gate') + 'coverage_report', 'leg_summary', 'pip_install', 'regenerate_samples', 'release_status', 'release_tags', + 'release_upload', 'release_vendor_update', 'update_python_versions', 'vendor_code_changed', + 'vendor_test_gate') #: The top-level modules the helper may import, each in Python 3.7's standard #: library: the running interpreter's :data:`sys.stdlib_module_names` would #: pass ``tomllib``, which 3.7 lacks. @@ -58,8 +59,10 @@ 'leg_summary': (['ktlo'], {'LEG': 'Python 3.8 (KTLO, non-blocking)', 'STEPS': '{}'}), 'pip_install': (['--help'], {}), 'regenerate_samples': (['--help'], {}), + 'release_status': (['--help'], {}), 'release_tags': (['--help'], {}), 'release_upload': (['--help'], {}), + 'release_vendor_update': (['--help'], {}), 'update_python_versions': (['--help'], {}), 'vendor_code_changed': (['--help'], {}), 'vendor_test_gate': (['--help'], {}), diff --git a/tests/project/test_discussion_report_2030.py b/tests/project/test_discussion_report_2030.py index 1e09b0d738..dac603dc16 100644 --- a/tests/project/test_discussion_report_2030.py +++ b/tests/project/test_discussion_report_2030.py @@ -602,7 +602,10 @@ def test_create_release_still_reads_vendor_update_alone(self) -> None: spec = load('cron-vendor.yml') self.assertNotIn('name', spec['jobs']['vendor-update']) self.assertEqual([job.get('name', job_id) for job_id, job in spec['jobs'].items()].count('vendor-update'), 1) - release = (WORKFLOWS / 'create-release.yml').read_text(encoding='utf-8') + # #1967: Create Release's vendor_update runs util/release_vendor_update.py, which selects it. + self.assertIn('run: python3 util/release_vendor_update.py', + (WORKFLOWS / 'create-release.yml').read_text(encoding='utf-8')) + release = (ROOT / 'util' / 'release_vendor_update.py').read_text(encoding='utf-8') self.assertIn('select(.name == "vendor-update")', release) def test_the_vendor_report_steps_never_fail_the_job(self) -> None: diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 2fd47b9749..05ed53ca25 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -55,6 +55,7 @@ from tests.project.test_python_versions_1660 import legs as expand from tests.project.test_python_versions_1660 import matrix, resolve, setups, workflow from tests.project.test_python_versions_1660 import script as versions_script +from tests.project.test_release_tags_the_run_sha_1634 import script_command if TYPE_CHECKING: from typing import Any, Optional @@ -237,6 +238,11 @@ def bash(script: 'str', env: 'dict[str, str]') -> 'subprocess.CompletedProcess[s capture_output=True, text=True, check=False, timeout=60) +def release_status() -> 'dict[str, Any]': + """``release_status``'s step that reports, the one after its checkout.""" + return next(step for step in job('create-release.yml', 'release_status')['steps'] if 'run' in step) + + class TestTheSupportedTierBlocks(unittest.TestCase): """The supported tier everywhere, and nothing softens a supported leg.""" @@ -407,7 +413,7 @@ def test_pypi_completeness_counts_the_supported_tier_only(self) -> None: def test_each_ktlo_leg_records_its_outcome_for_release_status(self) -> None: spec = job('create-release.yml', 'pypi') record = next(step for step in spec['steps'] if step.get('id') == 'ktlo') - status = job('create-release.yml', 'release_status')['steps'][0]['env'] + status = release_status()['env'] for leg in (leg for leg in legs('create-release.yml', 'pypi') if is_ktlo(leg)): key = 'ktlo_' + leg['python-version'].replace('.', '') with self.subTest(leg=leg['python-version']): @@ -423,7 +429,9 @@ def test_each_ktlo_leg_records_its_outcome_for_release_status(self) -> None: self.assertEqual(output.read_text(encoding='utf-8'), f'{key}={expected}\n') def test_release_status_reports_a_failed_ktlo_leg_without_warning(self) -> None: - script = job('create-release.yml', 'release_status')['steps'][0]['run'] + # #1967: ``python3 util/release_status.py``, on this interpreter. + command = script_command(release_status()['run']) + assert command is not None base = {'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '', 'PCAPKIT_UNIT_TESTS': 'success', 'PCAPKIT_VERSION_CHECK': 'success', 'PCAPKIT_GITHUB_JOB': 'success', 'PCAPKIT_TAG_JOB': 'success', @@ -431,9 +439,10 @@ def test_release_status_reports_a_failed_ktlo_leg_without_warning(self) -> None: 'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_TAG_EXISTS': 'false', 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', 'PCAPKIT_CONDA_COMPLETE': 'false'} - clean = bash(script, base) - failed = bash(script, dict(base, PCAPKIT_PYPI_KTLO_310='failure', - PCAPKIT_PYPI_KTLO_PYPY310='failure')) + clean, failed = (subprocess.run(command, env=dict(os.environ, **env), capture_output=True, text=True, + check=False, timeout=60) + for env in (base, dict(base, PCAPKIT_PYPI_KTLO_310='failure', + PCAPKIT_PYPI_KTLO_PYPY310='failure'))) self.assertEqual((clean.returncode, failed.returncode), (0, 0), failed.stderr) self.assertIn('::notice title=Release complete::', failed.stdout) self.assertRegex(failed.stdout, r'::notice title=KTLO legs \(non-blocking\)::pypi 3\.10=failure ' diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 055dcbf5ea..b9f16f78be 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -528,10 +528,9 @@ def block_scalar_after(block: 'str', key: 're.Pattern[str]') -> 'str': first line in ``block`` matching ``key``. Only used for :data:`_RUN_LITERAL` against a block holding exactly one - ``run:`` -- ``release_status``, which has one step, or a single step - narrowed by :func:`step_block` -- since it is ambiguous for a block with - more than one, which is why this is not the general-purpose - ``run:`` extractor the rest of this module reaches for. Dedents by the + ``run:`` -- a single step narrowed by :func:`step_block` -- since it is + ambiguous for a block with more than one, which is why this is not the + general-purpose ``run:`` extractor the rest of this module reaches for. Dedents by the body's *own* first line, the same rule a real YAML literal block scalar follows, rather than assuming a fixed indent. @@ -3169,6 +3168,11 @@ def test_the_test_gate_is_not_mistaken_for_a_publisher(self) -> None: self.assertEqual(publishing_reasons(self.jobs[name]), []) +#: ``release_status``'s step, and the script it runs since #1967. +RELEASE_STATUS_STEP = 'Report why this run did or did not release anything' +RELEASE_STATUS_SCRIPT = 'util/release_status.py' + + class TestReleaseStatusReportsHonestly(WorkflowMixin, unittest.TestCase): """#888's other job: a run that released nothing must say why, in one place, regardless of which upstream job or gate made it release nothing. @@ -3217,23 +3221,27 @@ def test_the_888_shape_is_reported_as_a_real_failure(self) -> None: the way every other branch does. """ - block = self.jobs['release_status'] - self.assertIn('::error title=Stranded partial release (#888)', block) - self.assertIn('exit 1', block) + # #1967: the step runs util/release_status.py, whose one exit 1 is this. + script = (ROOT / RELEASE_STATUS_SCRIPT).read_text(encoding='utf-8') + self.assertIn(f'run: python3 {RELEASE_STATUS_SCRIPT}', self.jobs['release_status']) + self.assertIn("say('error', 'Stranded partial release (#888)',", script) + after = script.split("say('error', 'Stranded partial release (#888)',")[1].split('\ndef ')[0] + self.assertEqual(after.count('return 1'), 1) def test_routine_skips_are_quiet(self) -> None: """The steady state -- nothing to release, or a release that just completed -- must not read as a problem. """ - block = self.jobs['release_status'] - self.assertIn('::notice title=Nothing to release', block) - self.assertIn('::notice title=Release complete', block) + script = (ROOT / RELEASE_STATUS_SCRIPT).read_text(encoding='utf-8') + self.assertIn("say('notice', 'Nothing to release',", script) + self.assertIn("say('notice', 'Release complete',", script) class TestReleaseStatusScriptExecutesCorrectly(WorkflowMixin, unittest.TestCase): - """Actually runs ``release_status``'s shell script, rather than only - grepping it, over a table of the shapes a real run can take. + """Actually runs ``release_status``'s script, rather than only grepping it, + over a table of the shapes a real run can take: the step's ``python3 + util/release_status.py`` (#1967), on this interpreter. A cross-review of the #888 change found that an earlier revision of this script demanded the same outcome (all four release jobs ``skipped``, or @@ -3249,9 +3257,10 @@ class TestReleaseStatusScriptExecutesCorrectly(WorkflowMixin, unittest.TestCase) @classmethod def setUpClass(cls) -> None: super().setUpClass() - if shutil.which('bash') is None: - raise unittest.SkipTest('bash not found on PATH') - cls.script = block_scalar_after(cls.jobs['release_status'], _RUN_LITERAL) # type: ignore[attr-defined] + step = step_block(cls.jobs['release_status'], RELEASE_STATUS_STEP) # type: ignore[attr-defined] + run = re.search(r'^\s*run: python3 (util/\w+\.py)\s*$', step, re.M) + assert run is not None, step + cls.command = [sys.executable, str(ROOT / run.group(1))] def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]': env = { @@ -3278,7 +3287,7 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] } env.update(overrides) return subprocess.run( # type: ignore[return-value] - ['bash', '-c', self.script], # type: ignore[attr-defined] + self.command, # type: ignore[attr-defined] env=env, capture_output=True, text=True, timeout=scale_timeout(10), ) diff --git a/tests/project/test_release_notice_states_no_totals_1849.py b/tests/project/test_release_notice_states_no_totals_1849.py index 1c21d1d14e..f716a4e133 100644 --- a/tests/project/test_release_notice_states_no_totals_1849.py +++ b/tests/project/test_release_notice_states_no_totals_1849.py @@ -6,7 +6,8 @@ while ``version_check`` derives both at release time from the tag's own tier file, so the notice went wrong once a supported version was added or retired, and for an older tag. The step is read with :func:`tests._workflows.steps` and its -script run in that state, so the notice checked is the one a run prints. +script, :file:`util/release_status.py` since #1967, run in that state, so the +notice checked is the one a run prints. """ @@ -15,12 +16,12 @@ import os import pathlib import re -import shutil import subprocess # nosec: B404 import unittest from tests import _workflows from tests._support import scale_timeout +from tests.project.test_release_tags_the_run_sha_1634 import script_command try: import yaml @@ -28,7 +29,6 @@ yaml = None # type: ignore[assignment] WORKFLOW = pathlib.Path(__file__).resolve().parents[2] / '.github' / 'workflows' / 'create-release.yml' -BASH = shutil.which('bash') #: Every publisher skipped with its own evidence present: the steady state. The #: version carries no digit, so any digit in the notice is the script's own. @@ -54,7 +54,6 @@ @unittest.skipIf(yaml is None, 'PyYAML, from the test extra, is not installed') -@unittest.skipIf(not BASH, 'needs bash on PATH') class TestNothingToReleaseStatesNoTotals(unittest.TestCase): """The notice says what is published without counting it.""" @@ -64,7 +63,9 @@ def notice(self) -> 'str': step = next(step for step in steps if 'run' in step) env = dict.fromkeys(step['env'], '') env.update(STEADY, PATH=os.environ.get('PATH', os.defpath)) - result = subprocess.run([BASH, '-c', step['run']], env=env, # nosec: B603 + command = script_command(step['run']) + self.assertIsNotNone(command, step['run']) + result = subprocess.run(command, env=env, # type: ignore[arg-type] # nosec: B603 capture_output=True, text=True, check=False, timeout=scale_timeout(10)) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) found = [line for line in result.stdout.splitlines() diff --git a/tests/project/test_release_reads_the_vendor_update_1748.py b/tests/project/test_release_reads_the_vendor_update_1748.py index 9b4bfa08d7..336b4cc1f3 100644 --- a/tests/project/test_release_reads_the_vendor_update_1748.py +++ b/tests/project/test_release_reads_the_vendor_update_1748.py @@ -14,8 +14,8 @@ 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``. +'false'``. Its script, :file:`util/release_vendor_update.py` since #1967, runs +here against a stand-in ``gh`` that applies the real ``--jq`` filter with ``jq``. """ @@ -32,7 +32,7 @@ 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 +from tests.project.test_release_tags_the_run_sha_1634 import script_command, workflow_steps if TYPE_CHECKING: from typing import Any, Optional @@ -58,6 +58,14 @@ ]} STEP = 'Read whether its vendor-update job ran' +#: ``release_status``'s one step that runs a script. +REPORT_STEP = 'Report why this run did or did not release anything' + + +def named(steps: 'list[dict[str, Any]]', name: 'str') -> 'dict[str, Any]': + """The one step of ``steps`` called ``name``.""" + [step] = [step for step in steps if step.get('name') == name] + return step class TestTheJob(unittest.TestCase): @@ -78,12 +86,13 @@ def test_it_runs_only_for_a_vendor_update_that_succeeded(self) -> None: 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: + def test_it_reads_the_triggering_runs_jobs_with_read_scopes_only(self) -> None: + """``actions: read`` for the listing; ``contents: read`` for the checkout of its script (#1967).""" block = self.jobs['vendor_update'] - self.assertIn(' permissions:\n actions: read\n outputs:', block) + self.assertIn(' permissions:\n actions: read\n contents: read\n outputs:', block) self.assertIn('updated: ${{ steps.updated.outputs.updated }}', block) - step = self.steps['vendor_update'][0] - self.assertEqual(step.get('name'), STEP) + step = named(self.steps['vendor_update'], STEP) + self.assertEqual(step.get('id'), 'updated') self.assertEqual(step.get('env'), {'GH_TOKEN': '${{ github.token }}', 'PCAPKIT_VENDOR_RUN_ID': '${{ github.event.workflow_run.id }}'}) self.assertNotIn('${{', step['run']) @@ -91,7 +100,7 @@ def test_it_reads_the_triggering_runs_jobs_with_actions_read_only(self) -> None: 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] + report = named(self.steps['release_status'], REPORT_STEP) 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: @@ -105,11 +114,18 @@ def test_only_a_skipped_vendor_update_job_stops_version_check(self) -> None: @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.""" + """``vendor_update``'s step against the two real shapes and the ones it cannot read. + + It runs ``python3 util/release_vendor_update.py`` (#1967), here on this + interpreter. + + """ @classmethod def setUpClass(cls) -> None: - cls.script = workflow_steps(WORKFLOW.read_text(encoding='utf-8'))['vendor_update'][0]['run'] + run = named(workflow_steps(WORKFLOW.read_text(encoding='utf-8'))['vendor_update'], STEP)['run'] + cls.command = script_command(run) + assert cls.command is not None, run def setUp(self) -> None: self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1748-')) @@ -117,7 +133,6 @@ def setUp(self) -> None: (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]': @@ -131,7 +146,7 @@ def run_step(self, reply: 'Optional[dict[str, Any]]', '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, + result = subprocess.run(self.command, cwd=self.tmp, 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') diff --git a/tests/project/test_release_status_scripts_1967.py b/tests/project/test_release_status_scripts_1967.py new file mode 100644 index 0000000000..2a8e1d5359 --- /dev/null +++ b/tests/project/test_release_status_scripts_1967.py @@ -0,0 +1,610 @@ +# -*- coding: utf-8 -*- +"""#1967: :file:`util/release_status.py` and :file:`util/release_vendor_update.py`, run as Create Release runs them. + +* :class:`TestTheFormerShellAgrees` runs the shell ``release_status``'s "Report + why this run did or did not release anything" and ``vendor_update``'s "Read + whether its vendor-update job ran" held before the scripts, on the cases + below, and each script on the same, and requires the same status, standard + output and error, ``$GITHUB_OUTPUT``, ``$GITHUB_STEP_SUMMARY`` and ``gh`` + calls. Both step bodies are kept here verbatim, and each case table is checked + to reach every branch of its shell. +* :class:`TestTheStepsRunTheScripts` pins each step to its script, after a + checkout of :file:`util/` alone, and the scripts to the standard library and + Python 3.7's syntax. + +The shell ran as a step with no ``shell:`` runs on the runner, ``bash -e``, and +``bash`` prefixes each of its own errors with the script's path and line, which +:meth:`_Stand.former` strips; the rest of standard error is compared whole. +What the steps report, case by case, is +:mod:`tests.project.test_release_gates`'s and +:mod:`tests.project.test_release_reads_the_vendor_update_1748`'s. + +""" + +from __future__ import annotations + +import ast +import os +import pathlib +import random +import re +import shutil +import subprocess # nosec: B404 +import sys +import tempfile +import textwrap +import unittest +from typing import TYPE_CHECKING + +from tests._workflows import load, steps +from tests.project.test_release_gates import job_blocks + +if TYPE_CHECKING: + from typing import Callable, Optional + +#: Test tier (#1958): runs the tree's own scripts and the workflow's former shell. +__tier__ = 'LATEST' + +ROOT = pathlib.Path(__file__).resolve().parents[2] +UTIL = ROOT / 'util' +WORKFLOW = 'create-release.yml' +BASH = shutil.which('bash') or '' + +RUN = '1' * 40 +OTHER = '2' * 40 +BOT = '41898282' +HUMAN = '5352553' + +#: A ``gh`` answering ``api`` with ``$GH_REPLY``'s bytes as its ``--jq`` output, or +#: failing with ``$GH_FAIL``'s status (``TERM``, killed by SIGTERM). +FAKE_GH = r'''#!/bin/sh +printf '%s\n' "$*" >> "$GH_LOG" +[ "$1" = api ] || exit 99 +if [ -n "${GH_FAIL:-}" ]; then + echo "HTTP 404: Not Found (https://api.github.com/$2)" >&2 + [ "$GH_FAIL" != TERM ] || kill -TERM $$ + exit "$GH_FAIL" +fi +cat "$GH_REPLY" +''' + +#: "Report why this run did or did not release anything" in ``release_status``, as +#: it held before :file:`util/release_status.py`. +FORMER_STATUS = textwrap.dedent(r''' + set -euo pipefail + + # #1633: pypi's KTLO legs reach neither needs.pypi.result + # (continue-on-error) nor PCAPKIT_PYPI_COMPLETE's count, so each is + # only reported here, as a notice, from its own job output. + ktlo_310="${PCAPKIT_PYPI_KTLO_310:-}" + ktlo_pypy310="${PCAPKIT_PYPI_KTLO_PYPY310:-}" + if [ -n "${ktlo_310}${ktlo_pypy310}" ]; then + echo "::notice title=KTLO legs (non-blocking)::pypi 3.10=${ktlo_310:-not run} pypy3.10=${ktlo_pypy310:-not run}. End-of-life Python, built and published but never blocking (#1633); neither result changes this run's verdict." + fi + + # Case 1: version_check never ran. On `workflow_run`, Vendor Update did + # not conclude `success`: `skipped`, nothing to update, gets a quiet + # notice; anything else a warning, as Vendor Update needs attention -- + # but no tag exists to strand, so it is not #888. On a push it is quiet + # only for the release App's own tag (#1634); an unset bot id never + # matches. #1748: a `success` that never ran 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 + echo "::warning title=Release skipped for an unexpected reason::version_check reported 'skipped' with event_name='${PCAPKIT_EVENT_NAME}', which is not the workflow_run non-success case this job otherwise expects. Check the version_check job's own if: condition." + fi + exit 0 + fi + + # version_check ran and did not succeed: a workflow failure, not #888. + if [ "$PCAPKIT_VERSION_CHECK" != "success" ]; then + echo "::warning title=Release blocked before it could start::version_check did not succeed (result: '${PCAPKIT_VERSION_CHECK}'), so neither the release test gate nor any release job ran. This is a workflow failure, not #888." + exit 0 + fi + + # The gate did not pass, so every release job skipped: a test failure, + # not #888, and already red in this run's checks. + if [ "$PCAPKIT_UNIT_TESTS" = "failure" ] || [ "$PCAPKIT_UNIT_TESTS" = "cancelled" ]; then + echo "::warning title=Release blocked by the test gate::the release test gate concluded '${PCAPKIT_UNIT_TESTS}', so no release job ran for ${PCAPKIT_VERSION}. This is a test failure, not #888." + exit 0 + fi + + # #1748: tagged at another commit, with the gate and every publisher + # skipped for it, which happens only off a v* ref. That release is that + # commit's (#1634): a notice if complete, else a warning naming what it + # lacks, both green. A publisher that ran 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 the evidence outputs are meaningful. A skipped gate (#1052) + # falls through on purpose: it skips only when every evidence output + # reads 'true' off a v* ref, reported as "Nothing to release" below; if + # its predicate drifts, every publisher skips behind it and this + # reaches the loud #888 branch instead. + # + # Reconcile each job against its OWN evidence, not against each other: + # a self-heal mixes jobs that skipped because their artefact existed + # with jobs that ran, and demanding uniformity would read that mix as + # #888 on the run that finished the release. Reconciled = succeeded, or + # skipped with its own evidence saying done. + reconciled_github=false + [ "$PCAPKIT_GITHUB_JOB" = "success" ] && reconciled_github=true + [ "$PCAPKIT_GITHUB_JOB" = "skipped" ] && [ "$PCAPKIT_TAG_EXISTS" = "true" ] && reconciled_github=true + + reconciled_tag=false + [ "$PCAPKIT_TAG_JOB" = "success" ] && reconciled_tag=true + [ "$PCAPKIT_TAG_JOB" = "skipped" ] && [ "$PCAPKIT_CONDA_TAG_EXISTS" = "true" ] && reconciled_tag=true + + reconciled_pypi=false + [ "$PCAPKIT_PYPI_JOB" = "success" ] && reconciled_pypi=true + [ "$PCAPKIT_PYPI_JOB" = "skipped" ] && [ "$PCAPKIT_PYPI_COMPLETE" = "true" ] && reconciled_pypi=true + + reconciled_conda=false + [ "$PCAPKIT_CONDA_JOB" = "success" ] && reconciled_conda=true + [ "$PCAPKIT_CONDA_JOB" = "skipped" ] && [ "$PCAPKIT_CONDA_COMPLETE" = "true" ] && reconciled_conda=true + + all_reconciled=false + if [ "$reconciled_github" = "true" ] && [ "$reconciled_tag" = "true" ] \ + && [ "$reconciled_pypi" = "true" ] && [ "$reconciled_conda" = "true" ]; then + all_reconciled=true + fi + + all_skipped=true + any_broken=false + for result in "$PCAPKIT_GITHUB_JOB" "$PCAPKIT_TAG_JOB" "$PCAPKIT_PYPI_JOB" "$PCAPKIT_CONDA_JOB"; do + [ "$result" = "skipped" ] || all_skipped=false + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + any_broken=true + fi + done + + # Case 2: every target reconciled by skipping -- the usual steady state. + if [ "$all_reconciled" = "true" ] && [ "$all_skipped" = "true" ]; then + echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag and every PyPI and Anaconda file version_check expects are present. This run correctly released nothing." + exit 0 + fi + + # Every target reconciled and at least one ran: a fresh release or a + # self-heal, both quiet. + if [ "$all_reconciled" = "true" ]; then + echo "::notice title=Release complete::pypcapkit ${PCAPKIT_VERSION} is now fully released -- github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB}. A 'skipped' entry there means that target's own artefact already existed before this run, not that anything was missed." + exit 0 + fi + + if [ "$any_broken" = "true" ]; then + echo "::warning title=Release did not complete::github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB} for ${PCAPKIT_VERSION}. At least one release job failed or was cancelled outright -- see its own log; this run's checks already show that failure in red." + exit 0 + fi + + # Case 3, the #888 shape: a target skipped despite its own evidence + # saying incomplete, with nothing failed or cancelled to explain it. + # The evidence-based `if:`s should make this unreachable, so reaching + # it means that assumption broke: loud, and never green. + echo "::error title=Stranded partial release (#888)::${PCAPKIT_VERSION} is only partially published (tag exists=${PCAPKIT_TAG_EXISTS}, conda tag exists=${PCAPKIT_CONDA_TAG_EXISTS}, pypi complete=${PCAPKIT_PYPI_COMPLETE}, conda complete=${PCAPKIT_CONDA_COMPLETE}) but github=${PCAPKIT_GITHUB_JOB} tag=${PCAPKIT_TAG_JOB} pypi=${PCAPKIT_PYPI_JOB} conda=${PCAPKIT_CONDA_JOB} -- at least one target skipped despite its own evidence saying it is not done. Re-running this workflow should let the incomplete jobs self-heal; if this recurs on the next run too, the evidence checks in version_check are the first thing to check." + exit 1 +''').lstrip() + +#: "Read whether its vendor-update job ran" in ``vendor_update``, as it held +#: before :file:`util/release_vendor_update.py`. +FORMER_VENDOR = textwrap.dedent(r''' + 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 +''').lstrip() + +#: Each job, the step its script runs as, the ``run:`` it now holds, and its +#: permissions. +JOBS = { + 'release_status': ('Report why this run did or did not release anything', 'python3 util/release_status.py', + {'contents': 'read'}), + 'vendor_update': ('Read whether its vendor-update job ran', 'python3 util/release_vendor_update.py', + {'actions': 'read', 'contents': 'read'}), +} +#: The checkout each job opens with: this run's commit, :file:`util/` alone. +CHECKOUT = {'ref': '${{ github.sha }}', 'sparse-checkout': 'util', 'persist-credentials': False} + +#: What ``release_status``'s variables hold unless a case says otherwise: a fresh +#: release on a Vendor Update that updated, every publisher succeeding. +STATUS_BASE = { + 'PCAPKIT_EVENT_NAME': 'workflow_run', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'success', + 'PCAPKIT_VENDOR_UPDATED': 'true', 'PCAPKIT_ACTOR_ID': HUMAN, 'PCAPKIT_RELEASE_APP_BOT_ID': BOT, + 'PCAPKIT_RUN_SHA': RUN, 'PCAPKIT_UNIT_TESTS': 'success', 'PCAPKIT_VERSION_CHECK': 'success', + 'PCAPKIT_GITHUB_JOB': 'success', 'PCAPKIT_TAG_JOB': 'success', 'PCAPKIT_PYPI_JOB': 'success', + 'PCAPKIT_CONDA_JOB': 'success', 'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_TAG_EXISTS': 'false', + 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', 'PCAPKIT_CONDA_COMPLETE': 'false', + 'PCAPKIT_RELEASED_AT': RUN, 'PCAPKIT_RELEASED_ELSEWHERE': 'false', 'PCAPKIT_PYPI_KTLO_310': '', + 'PCAPKIT_PYPI_KTLO_PYPY310': '', +} +#: Every publisher skipped, and each one's evidence. +ALL_SKIPPED = {'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', 'PCAPKIT_PYPI_JOB': 'skipped', + 'PCAPKIT_CONDA_JOB': 'skipped'} +ALL_DONE = {'PCAPKIT_TAG_EXISTS': 'true', 'PCAPKIT_CONDA_TAG_EXISTS': 'true', 'PCAPKIT_PYPI_COMPLETE': 'true', + 'PCAPKIT_CONDA_COMPLETE': 'true'} +SKIPPED_CHECK = {'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped', **ALL_SKIPPED} +ELSEWHERE = {'PCAPKIT_UNIT_TESTS': 'skipped', **ALL_SKIPPED, 'PCAPKIT_RELEASED_ELSEWHERE': 'true', + 'PCAPKIT_RELEASED_AT': OTHER} +#: A value standing for a variable left unset. +UNSET = None + +#: ``release_status``'s cases: ``(name, overrides of STATUS_BASE)``. +STATUS_CASES = ( + ('the release app pushed the tag', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': 'push', + 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '', 'PCAPKIT_ACTOR_ID': BOT}), + ('a person pushed the tag', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': 'push', + 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': ''}), + ('no release app and no actor', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_ACTOR_ID': '', + 'PCAPKIT_RELEASE_APP_BOT_ID': '', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': ''}), + ('the bot id on a workflow_run', {**SKIPPED_CHECK, 'PCAPKIT_ACTOR_ID': BOT, + 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'skipped'}), + ('vendor update had nothing to do', {**SKIPPED_CHECK, 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'skipped'}), + ('vendor update updated nothing', {**SKIPPED_CHECK, 'PCAPKIT_VENDOR_UPDATED': 'false'}), + ('vendor update unread', {**SKIPPED_CHECK, 'PCAPKIT_VENDOR_UPDATED': ''}), + ('vendor update failed', {**SKIPPED_CHECK, 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'failure'}), + ('vendor update updated nothing but failed', {**SKIPPED_CHECK, 'PCAPKIT_VENDOR_UPDATED': 'false', + 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'failure'}), + ('an unexpected event', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': 'workflow_dispatch'}), + ('version check failed', {'PCAPKIT_VERSION_CHECK': 'failure', 'PCAPKIT_UNIT_TESTS': 'skipped', **ALL_SKIPPED}), + ('version check cancelled', {'PCAPKIT_VERSION_CHECK': 'cancelled', **ALL_SKIPPED}), + ('version check empty', {'PCAPKIT_VERSION_CHECK': ''}), + ('the gate failed', {'PCAPKIT_UNIT_TESTS': 'failure', **ALL_SKIPPED}), + ('the gate was cancelled', {'PCAPKIT_UNIT_TESTS': 'cancelled', **ALL_SKIPPED, **ALL_DONE}), + ('the gate skipped with nothing publishable', {'PCAPKIT_UNIT_TESTS': 'skipped', **ALL_SKIPPED, **ALL_DONE}), + ('the gate skipped with work outstanding', {'PCAPKIT_UNIT_TESTS': 'skipped', **ALL_SKIPPED, **ALL_DONE, + 'PCAPKIT_PYPI_COMPLETE': 'false'}), + ('elsewhere and complete', {**ELSEWHERE, **ALL_DONE}), + ('elsewhere lacking the conda tag', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_CONDA_TAG_EXISTS': 'false'}), + ('elsewhere lacking pypi', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_PYPI_COMPLETE': ''}), + ('elsewhere lacking anaconda', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_CONDA_COMPLETE': 'false'}), + ('elsewhere lacking pypi and anaconda', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_VERSION': '1.5.0b12', + 'PCAPKIT_PYPI_COMPLETE': 'false', 'PCAPKIT_CONDA_COMPLETE': 'false'}), + ('elsewhere lacking everything', {**ELSEWHERE}), + ('elsewhere and a publisher ran', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_CONDA_JOB': 'failure'}), + ('elsewhere with no owner output', {**ALL_SKIPPED, 'PCAPKIT_TAG_EXISTS': 'true', + 'PCAPKIT_RELEASED_ELSEWHERE': '', 'PCAPKIT_RELEASED_AT': ''}), + ('steady state', {**ALL_SKIPPED, **ALL_DONE}), + ('a fresh release', {}), + ('a self-heal', {'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_JOB': 'skipped', 'PCAPKIT_TAG_EXISTS': 'true', + 'PCAPKIT_CONDA_TAG_EXISTS': 'true'}), + ('each target reconciled by skipping but one', {'PCAPKIT_GITHUB_JOB': 'skipped', 'PCAPKIT_TAG_EXISTS': 'true', + 'PCAPKIT_TAG_JOB': 'skipped', + 'PCAPKIT_CONDA_TAG_EXISTS': 'true', + 'PCAPKIT_PYPI_JOB': 'skipped', 'PCAPKIT_PYPI_COMPLETE': 'true'}), + ('a publisher failed', {'PCAPKIT_PYPI_JOB': 'failure', **ALL_DONE, 'PCAPKIT_PYPI_COMPLETE': 'false'}), + ('a publisher was cancelled', {'PCAPKIT_GITHUB_JOB': 'cancelled', 'PCAPKIT_TAG_JOB': 'skipped'}), + ('the 888 shape', {**ALL_SKIPPED, 'PCAPKIT_TAG_EXISTS': 'true'}), + ('a publisher with no result', {'PCAPKIT_CONDA_JOB': ''}), + ('both ktlo legs failed', {'PCAPKIT_PYPI_KTLO_310': 'failure', 'PCAPKIT_PYPI_KTLO_PYPY310': 'failure'}), + ('one ktlo leg recorded', {**SKIPPED_CHECK, 'PCAPKIT_PYPI_KTLO_PYPY310': 'success', + 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'skipped'}), + ('the other ktlo leg recorded', {'PCAPKIT_PYPI_KTLO_310': 'success', **ALL_SKIPPED, 'PCAPKIT_TAG_EXISTS': 'true'}), + ('ktlo legs unset', {'PCAPKIT_PYPI_KTLO_310': UNSET, 'PCAPKIT_PYPI_KTLO_PYPY310': UNSET}), + ('the owner outputs unset', {**ALL_SKIPPED, **ALL_DONE, 'PCAPKIT_RELEASED_ELSEWHERE': UNSET, + 'PCAPKIT_VENDOR_UPDATED': UNSET}), + ('awkward values pass through', {'PCAPKIT_VERSION_CHECK': "100% a:b, c\\nd 'e' $f `g`", **ALL_SKIPPED, + 'PCAPKIT_PYPI_KTLO_310': '%0A::x'}), + ('a value holding a line break', {'PCAPKIT_UNIT_TESTS': 'failure', 'PCAPKIT_VERSION': '1.5.0\nv2'}), + ('a value not in utf-8', {'PCAPKIT_VERSION': os.fsdecode(b'1.5.0\xff'), **ALL_SKIPPED, **ALL_DONE}), + ('the check result unset', {'PCAPKIT_VERSION_CHECK': UNSET, 'PCAPKIT_PYPI_KTLO_310': 'success'}), + ('the event unset', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': UNSET}), + ('the bot id unset on a push', {**SKIPPED_CHECK, 'PCAPKIT_EVENT_NAME': 'push', + 'PCAPKIT_RELEASE_APP_BOT_ID': UNSET}), + ('the version unset elsewhere', {**ELSEWHERE, 'PCAPKIT_VERSION': UNSET}), + ('an evidence output unset', {**ALL_SKIPPED, 'PCAPKIT_CONDA_COMPLETE': UNSET}), + ('the run sha unset where it is named', {**ELSEWHERE, **ALL_DONE, 'PCAPKIT_RUN_SHA': UNSET}), +) + +#: Each way the former ``release_status`` shell ends: ``name -> (status, its last +#: line of standard output, as a pattern)``. Every case matches one, and every one +#: is matched. +STATUS_BRANCHES = { + "the release app's tag": (0, r"::notice title=Nothing to release::the release App's bot"), + 'vendor update skipped': (0, r'::notice title=Nothing to release::the triggering Vendor Update run found '), + 'vendor-update skipped': (0, r'::notice title=Nothing to release::the triggering Vendor Update run skipped '), + 'vendor update did not succeed': (0, r'::warning title=Release skipped::'), + 'an unexpected skip': (0, r'::warning title=Release skipped for an unexpected reason::'), + 'version check did not succeed': (0, r'::warning title=Release blocked before it could start::'), + 'the gate did not pass': (0, r'::warning title=Release blocked by the test gate::'), + 'released elsewhere, complete': (0, r'::notice title=Nothing to release::v.* names '), + 'released elsewhere, unfinished': (0, r'::warning title=v.* is unfinished \(#1634\)::'), + 'already released': (0, r'::notice title=Nothing to release::pypcapkit '), + 'released': (0, r'::notice title=Release complete::'), + 'a publisher broke': (0, r'::warning title=Release did not complete::'), + 'the 888 shape': (1, r'::error title=Stranded partial release \(#888\)::'), + 'an unset variable': (1, r'(?!::(?:notice|warning|error) title=(?!KTLO))'), +} + +#: Vendor Update's job listing as ``gh --jq`` prints it: the cases' replies. +VENDOR_CASES = ( + ('run 38650782750 updated', b'success\n', {}), + ('run 38650191283 updated nothing', b'skipped\n', {}), + ('vendor-update failed', b'failure\n', {}), + ('vendor-update was cancelled', b'cancelled\n', {}), + ('no vendor-update job', b'', {}), + ('two vendor-update jobs', b'success\nskipped\n', {}), + ('trailing blank lines are dropped', b'success\n\n\n', {}), + ('no trailing newline', b'skipped', {}), + ('a leading blank line is kept', b'\nsuccess\n', {}), + ('a carriage return is kept', b'success\r\n', {}), + ('spaces are kept', b' success\n', {}), + ('awkward text passes through', b"100% a:b, c 'd' $e\n", {}), + ('text not in utf-8', b'succ\xffess\n', {}), + ('the api refused', b'success\n', {'GH_FAIL': '1'}), + ('the api refused with another status', b'', {'GH_FAIL': '4'}), + ('gh was killed', b'success\n', {'GH_FAIL': 'TERM'}), + ('no gh at all', b'success\n', {'PATH': '/nonexistent'}), + ('no output file for success', b'success\n', {'GITHUB_OUTPUT': UNSET}), + ('no output file for skipped', b'skipped\n', {'GITHUB_OUTPUT': UNSET}), + ('no output file for a warning', b'failure\n', {'GITHUB_OUTPUT': UNSET}), + ('no repository', b'success\n', {'GITHUB_REPOSITORY': UNSET}), + ('no run id', b'success\n', {'PCAPKIT_VENDOR_RUN_ID': UNSET}), + ('neither', b'success\n', {'PCAPKIT_VENDOR_RUN_ID': UNSET, 'GITHUB_REPOSITORY': UNSET}), +) + +#: Each way the former ``vendor_update`` shell ends: ``name -> (status, +#: $GITHUB_OUTPUT, its standard output, as a pattern)``. +VENDOR_BRANCHES = { + 'not listed': (0, '', r'::warning title=Vendor Update not read \(#1748\)::could not list the jobs of '), + 'updated': (0, 'updated=true\n', r'$'), + 'updated nothing': (0, 'updated=false\n', r'Vendor Update run \d+ skipped vendor-update: '), + 'not read': (0, '', r'::warning title=Vendor Update not read \(#1748\)::Vendor Update run \d+ reports '), + 'an unset variable': (1, '', r'$'), +} + + +class _Stand(unittest.TestCase): + """A scratch directory with the stand-in ``gh`` first on ``PATH``.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1967-status-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + self.bin = self.tmp / 'bin' + self.bin.mkdir() + (self.bin / 'gh').write_text(FAKE_GH, encoding='utf-8') + (self.bin / 'gh').chmod(0o755) + self.work = self.tmp / 'work' + self.work.mkdir() + self.output = self.tmp / 'output' + self.summary = self.tmp / 'summary' + self.gh_log = self.tmp / 'gh.log' + self.reply = self.tmp / 'reply' + self.shell = self.tmp / 'step.sh' + + def env(self, base: 'dict[str, str]', overrides: 'dict[str, Optional[str]]') -> 'dict[str, str]': + env = {'PATH': f'{self.bin}{os.pathsep}{os.environ.get("PATH", os.defpath)}', 'HOME': str(self.tmp), + 'LC_ALL': 'C', 'GITHUB_OUTPUT': str(self.output), 'GITHUB_STEP_SUMMARY': str(self.summary), + 'GITHUB_REPOSITORY': 'JarryShaw/PyPCAPKit', 'GH_LOG': str(self.gh_log), 'GH_REPLY': str(self.reply), + **base} + for name, value in overrides.items(): + if value is UNSET: + env.pop(name, None) + else: + env[name] = value + return env + + def state(self, command: 'list[str]', env: 'dict[str, str]', reply: 'bytes') -> 'tuple[object, ...]': + """What ``command`` left: status, both streams, ``$GITHUB_OUTPUT``, ``$GITHUB_STEP_SUMMARY`` and ``gh``'s calls. + + Both files hold a line already, as a job's earlier steps may have left. + + """ + for path in (self.output, self.summary, self.gh_log): + if path.exists(): + path.unlink() + self.output.write_text('earlier=1\n', encoding='utf-8') + self.summary.write_text('earlier\n', encoding='utf-8') + self.reply.write_bytes(reply) + result = subprocess.run(command, cwd=self.work, env=env, capture_output=True, # nosec: B603 + check=False, timeout=120) + return (result.returncode, result.stdout.decode('utf-8', 'surrogateescape'), + result.stderr.decode('utf-8', 'surrogateescape'), + *(path.read_text(encoding='utf-8', errors='surrogateescape') if path.exists() else None + for path in (self.output, self.summary, self.gh_log))) + + def former(self, shell: 'str', env: 'dict[str, str]', reply: 'bytes' = b'') -> 'tuple[object, ...]': + """``shell`` as a step with no ``shell:`` runs it, ``bash -e``, its own errors' ``: line N:`` dropped.""" + self.shell.write_text(shell, encoding='utf-8') + found = self.state([BASH, '-e', str(self.shell)], env, reply) + stderr = re.sub(rf'(?m)^{re.escape(str(self.shell))}: line \d+: ', '', str(found[2])) + return (*found[:2], stderr, *found[3:]) + + def run_both(self, shell: 'str', script: 'str', env: 'dict[str, str]', + reply: 'bytes' = b'') -> 'tuple[tuple[object, ...], tuple[object, ...]]': + """The state each leaves: :meth:`former` on ``shell``, then ``python3 util/