diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index fc218f662d..b2d857331b 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -22,6 +22,11 @@ concurrency: group: conda-update-${{ github.event.workflow_run.head_sha || github.ref }} cancel-in-progress: false +# #2030: one Discussions category holds every report with no pull request to +# comment on, one thread per job; cron-vendor.yml's `discussion` job says more. +env: + REPORT_CATEGORY: CI reports + jobs: # #1967: the test gate on the schedule, `verdict` on a `workflow_run`; # shared with Vendor Update and GitHub Pages. @@ -95,9 +100,13 @@ jobs: # (#1643). An unreadable package keeps its pin, as does a pin no release # can move to (#2025) and a pin whose own release has lost a wheel while # an older one still qualifies (#2028), and each warns, as does a pin past - # its review-by date; none fails the step. + # its review-by date; none fails the step. The log keeps those lines for + # the run report (#2030); `pipefail`, so `tee` cannot hide the script's + # own exit status. - name: Refresh the platform pins - run: python util/conda_vendor.py --refresh-pins + run: | + set -o pipefail + python util/conda_vendor.py --refresh-pins | tee "$RUNNER_TEMP/platform-pins.log" - name: Setup Conda Distribution run: | @@ -228,6 +237,67 @@ jobs: echo "PCAPKIT_BUILD=$(cat conda/build)" } >> "$GITHUB_OUTPUT" + # #2030: the run's outcome for the `discussion` job to post: what the pin + # refresh moved or warned about, the files the run changed, and the build + # it pushed. `status=warning` where a pin warned, which is what needs a + # hand. `continue-on-error` on both, so a report never fails the bump. + - name: Write the run report + id: run-report + if: ${{ !cancelled() }} + continue-on-error: true + env: + FILES_CHANGED: ${{ steps.verify-changed-files.outputs.files_changed }} + CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} + PUSH: ${{ steps.push-changes.outcome }} + COMMIT: ${{ steps.push-changes.outputs.commit }} + run: | + pins="$RUNNER_TEMP/platform-pins.log" + if grep -q '^::warning title=Platform pins::' "$pins" 2> /dev/null; then + echo "status=warning" >> "$GITHUB_OUTPUT" + else + echo "status=success" >> "$GITHUB_OUTPUT" + fi + { + echo '### Conda Update' + echo + if [ ! -f "$pins" ]; then + echo '- Platform pins: not refreshed, as the job stopped first' + elif grep -q '^::[a-z]* title=Platform pins::' "$pins"; then + echo '- Platform pins:' + # The workflow command's data escapes, undone; `%25` last. + grep '^::[a-z]* title=Platform pins::' "$pins" | + sed -e 's/^::\([a-z]*\) title=Platform pins::/ - \1: /' \ + -e 's/%0D//g' -e 's/%0A/ /g' -e 's/%25/%/g' + else + echo '- Platform pins: none moved, and none warned' + fi + if [ "$FILES_CHANGED" = true ]; then + echo "- Changed: $CHANGED_FILES" + elif [ "$FILES_CHANGED" = false ]; then + echo '- Changed: nothing, so no build was bumped' + fi + # `commit` is written only beside `pushed=true`. + if [ -n "$COMMIT" ]; then + echo "- Pushed: build $(cat conda/build) as $COMMIT" + elif [ "$PUSH" = success ]; then + echo "- Pushed: nothing; Push Changes' notice says why" + elif [ "$PUSH" = failure ]; then + echo '- Pushed: nothing, as Push Changes failed' + elif [ "$FILES_CHANGED" != false ]; then + echo '- Pushed: nothing, as the job stopped first' + fi + } > "$RUNNER_TEMP/conda-report.md" + cat "$RUNNER_TEMP/conda-report.md" + + - name: Upload the run report + if: ${{ !cancelled() }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: conda-report + path: ${{ runner.temp }}/conda-report.md + retention-days: 14 + outputs: # Not `files_changed`: see Push Changes above. CONDA_CHANGED: ${{ steps.push-changes.outputs.pushed }} @@ -237,6 +307,8 @@ jobs: PCAPKIT_CONDA_LABEL: ${{ steps.get_version.outputs.PCAPKIT_CONDA_LABEL }} PCAPKIT_BUILD: ${{ steps.get_version.outputs.PCAPKIT_BUILD }} PYTHON_CONDA: ${{ steps.python-versions.outputs.conda }} + # #2030: `warning` where a platform pin warned without failing the job. + status: ${{ steps.run-report.outputs.status }} conda-tag: # See create-release.yml: "(update)" keeps this apart from the Conda Tag job @@ -557,3 +629,77 @@ jobs: omitPrereleaseDuringUpdate: true tag: "v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}" token: "${{ secrets.GITHUB_TOKEN }}" + + # #2030: posts `conda-update`'s report with the tag and upload results, and a + # short one of its own where `head` failed or was cancelled, as cron-vendor.yml's + # `discussion` job does, whose comment gives the rules. It waits for + # `conda-dist`, so the report says whether the build reached Anaconda. + discussion: + name: Post the conda report + needs: [ head, conda-update, conda-tag, conda-dist ] + if: >- + ${{ always() && (needs.conda-update.result != 'skipped' || + needs.head.result == 'failure' || needs.head.result == 'cancelled') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + permissions: + contents: read + discussions: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # A run that never wrote a report uploaded none; the script then warns. + - name: Download the report + if: needs.conda-update.result != 'skipped' + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: conda-report + path: report + + # As cron-vendor.yml's: a `workflow_run` posts as a push run, the + # schedule every run. A failed tag or upload fails the report too, and a + # green `conda-update` that warned reports `warning`. + - name: Post the report to Discussions + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ github.event_name == 'workflow_run' && 'push' || 'scheduled' }} + HEAD_RESULT: ${{ needs.head.result }} + UPDATE_RESULT: ${{ needs.conda-update.result }} + UPDATE_STATUS: ${{ needs.conda-update.outputs.status }} + TAG_RESULT: ${{ needs.conda-tag.result }} + DIST_RESULT: ${{ needs.conda-dist.result }} + run: | + if [ "$UPDATE_RESULT" = skipped ]; then + status="$HEAD_RESULT" + mkdir -p report + { + echo '### Conda Update' + echo + echo "- Gate: $HEAD_RESULT, so conda-update did not run and nothing was built or pushed" + } > report/conda-report.md + else + if [ "$UPDATE_RESULT" != success ]; then + status="$UPDATE_RESULT" + elif [ "$TAG_RESULT" = failure ] || [ "$DIST_RESULT" = failure ]; then + status=failure + elif [ "$TAG_RESULT" = cancelled ] || [ "$DIST_RESULT" = cancelled ]; then + status=cancelled + else + status="${UPDATE_STATUS:-success}" + fi + # Both skip unless a build was pushed. + if [ "$TAG_RESULT" != skipped ] && [ -f report/conda-report.md ]; then + { + echo "- Tag: $TAG_RESULT" + echo "- Upload: $DIST_RESULT" + } >> report/conda-report.md + fi + fi + python3 util/discussion_report.py --category "$REPORT_CATEGORY" \ + --title 'cron-conda: conda distribution update report' --report report/conda-report.md \ + --mode "$MODE" --status "$status" diff --git a/.github/workflows/cron-python-versions.yml b/.github/workflows/cron-python-versions.yml index da6325de5a..76fd2b6b11 100644 --- a/.github/workflows/cron-python-versions.yml +++ b/.github/workflows/cron-python-versions.yml @@ -9,7 +9,9 @@ name: Python Versions # # `pr` pushes with the release App's token, not GITHUB_TOKEN, whose pushes # start nothing, so the pull request's own checks run; none of them runs this -# workflow again. +# workflow again. The `discussion` job posts `detect`'s report to this job's +# thread in the Discussions category REPORT_CATEGORY names (#2030); it alone +# holds `discussions: write`, and a report it cannot post is a warning. on: schedule: - cron: '41 5 * * 1' # Mondays at 05:41 UTC @@ -24,6 +26,11 @@ concurrency: group: python-versions cancel-in-progress: false +# #2030: one Discussions category holds every report with no pull request to +# comment on, one thread per job. +env: + REPORT_CATEGORY: CI reports + jobs: # No secret and a read-only token. The computation reads the network and # writes only this checkout, and the audit is the repository's own tests. @@ -37,6 +44,8 @@ jobs: outputs: changed: ${{ steps.update.outputs.changed }} publish: ${{ steps.update.outputs.publish }} + # #2030: `warning` where the run asks for a hand without failing. + status: ${{ steps.run-report.outputs.status }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -119,6 +128,84 @@ jobs: echo "::error title=The audit fails on main::the tiers need no change, but the audit fails; see above." exit 1 + # #2030: what the run found, for the `discussion` job to post, from the + # plan `update` wrote: each tier that moved and why, the notes it raised, + # the audit, and what becomes of the proposal. `status=warning` where a + # note asks for a hand edit, the audit of a changed tree failed, or no + # pull request can open. `continue-on-error` on both, so a report never + # fails the run. + - name: Write the run report + id: run-report + if: ${{ !cancelled() }} + continue-on-error: true + env: + UPDATE: ${{ steps.update.outcome }} + CHANGED: ${{ steps.update.outputs.changed }} + PUBLISH: ${{ steps.update.outputs.publish }} + AUDIT: ${{ steps.audit.outcome }} + APP: ${{ vars.RELEASE_APP_CLIENT_ID != '' }} + ON_MAIN: ${{ github.ref == 'refs/heads/main' }} + run: | + python3 - "$RUNNER_TEMP/proposal/plan.json" "$RUNNER_TEMP/python-versions-report.md" <<'PY' + import json, os, pathlib, sys + + env = os.environ + source, target = map(pathlib.Path, sys.argv[1:]) + plan = json.loads(source.read_text(encoding='utf-8')) if source.is_file() else None + + + def tier(entry): + if entry is None: + return '--' + name = 'KTLO' if entry['tier'] == 'ktlo' else entry['tier'] + return f"{name} on `{entry['runner']}`" if 'runner' in entry else name + + + lines, warned = ['### Python Versions', ''], False + if plan is None: + lines.append('- Tiers: not computed, as the job stopped first') + else: + moves = [f" - `{c['version']}`: {tier(c['before'])} -> {tier(c['after'])} ({c['reason']})" + for c in plan['changes']] + if plan['latest'][0] != plan['latest'][1]: + moves.append(f" - `latest`: {plan['latest'][0]} -> {plan['latest'][1]}") + lines += ['- Tiers moved:', *moves] if moves else ['- Tiers: unchanged'] + if plan['changed_files']: + lines.append('- Regenerated: ' + ', '.join(f'`{path}`' for path in plan['changed_files'])) + for kind in ('remove', 'add'): + lines += [f'- Ruleset 23497679, by hand: {kind} `{name}`' for name in plan['required'][kind]] + lines += [f"- {note['kind']}: {note['text']}" for note in plan['notes']] + warned = any(note['kind'] != 'held' for note in plan['notes']) + lines.append(f"- Audit: {env.get('AUDIT') or 'not run'}") + warned = warned or (env.get('CHANGED') == 'true' and env.get('AUDIT') != 'success') + if env.get('UPDATE') != 'success': + lines.append('- Pull request: none, as Recompute the tiers failed') + elif env.get('CHANGED') != 'true': + lines.append('- Pull request: none needed, as nothing changed') + elif env.get('PUBLISH') != 'true': + lines.append('- Pull request: none, as `bot/python-versions` already proposes these tiers') + elif env.get('APP') != 'true': + lines.append('- Pull request: none, as `vars.RELEASE_APP_CLIENT_ID` is unset') + warned = True + elif env.get('ON_MAIN') != 'true': + lines.append('- Pull request: none, as this run is not on `main`') + else: + lines.append('- Pull request: the `pr` job opens or updates it once `github-release` is approved') + target.write_text('\n'.join(lines) + '\n', encoding='utf-8') + with open(env['GITHUB_OUTPUT'], 'a', encoding='utf-8') as file: + file.write(f"status={'warning' if warned else 'success'}\n") + PY + cat "$RUNNER_TEMP/python-versions-report.md" + + - name: Upload the run report + if: ${{ !cancelled() }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: python-versions-report + path: ${{ runner.temp }}/python-versions-report.md + retention-days: 14 + # The release App's key is a secret of the `github-release` environment, # which needs the owner's approval, so he is asked only when there is # something to propose. The token has no `workflows` permission, so it cannot @@ -168,3 +255,49 @@ jobs: APP_SLUG: ${{ steps.app.outputs.app-slug }} APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }} run: python3 util/update_python_versions.py publish "$RUNNER_TEMP/proposal" + + # #2030: posts `detect`'s report, on `always()` so a run failed by the audit + # or the script is posted too. Not after `pr`, which waits on the owner's + # approval of `github-release`; the pull request it opens is its own record. + # util/discussion_report.py writes a warning rather than failing; + # `continue-on-error` covers the rest. + discussion: + name: Post the Python version report + needs: [ detect ] + if: ${{ always() && needs.detect.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + permissions: + contents: read + discussions: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # A run that never wrote a report uploaded none; the script then warns. + - name: Download the report + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: python-versions-report + path: report + + # `scheduled` on dispatch too: a manual run is a deliberate report. A + # green `detect` that asked for a hand reports `warning`. + - name: Post the report to Discussions + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + DETECT_RESULT: ${{ needs.detect.result }} + DETECT_STATUS: ${{ needs.detect.outputs.status }} + run: | + status="$DETECT_RESULT" + if [ "$DETECT_RESULT" = success ] && [ -n "$DETECT_STATUS" ]; then + status="$DETECT_STATUS" + fi + python3 util/discussion_report.py --category "$REPORT_CATEGORY" \ + --title 'cron-python-versions: Python version update report' \ + --report report/python-versions-report.md --mode scheduled --status "$status" diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 6afe5eca93..8bdd5bcd97 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1217,7 +1217,8 @@ does; each has tests under :file:`tests/project/` that run it. ``gate``, at ``error``. * - :file:`util/discussion_report.py` - ``Post the report to Discussions`` in the ``discussion`` job of - ``ci-timing.yml`` and of ``cron-vendor.yml`` (see `CI Reports`_). + ``ci-timing.yml``, ``cron-vendor.yml``, ``cron-conda.yml`` and + ``cron-python-versions.yml`` (see `CI Reports`_). * - :file:`util/apt_packages.py` - The apt steps of ``unit-tests.yml``'s ``engine-tests`` and ``pypcap-parity`` (and so its KTLO twin): the plan, seed, install and collect steps, as @@ -1275,7 +1276,27 @@ reports ``warning`` where ``vendor-update`` passed over a failed target or test gate. A failed or cancelled ``head`` skips ``vendor-update``, so its ``discussion`` job runs on ``always()`` and then posts a line of its own with ``head``'s result; a skip on purpose, on a pull request's ``workflow_run`` or a -verdict for another commit, skips it too. The category is the workflow-level ``REPORT_CATEGORY``. The API cannot +verdict for another commit, skips it too. + +``cron-conda.yml`` posts to ``cron-conda: conda distribution update report`` by +the same rules: ``push`` on its ``workflow_run``, ``scheduled`` otherwise, and a +line of its own for a failed or cancelled ``head``. Its report lists each ``Platform pins`` notice and +warning from ``Refresh the platform pins``, whose output that step keeps in a log, +the files the run changed, and the build Push Changes pushed, or that it pushed +none. Its ``discussion`` job waits for ``conda-tag`` and ``conda-dist`` and adds +their results, so a failed tag or upload posts as ``failure``. A pin warning +reports ``warning``. ``cron-python-versions.yml`` runs only on ``schedule`` and +``workflow_dispatch``, so it posts as ``scheduled`` to ``cron-python-versions: +Python version update report``. ``detect``'s report, read from the plan +``Recompute the tiers`` writes, lists each tier that moved and why, the files it +regenerated, the ruleset edits to make by hand, the script's notes, the audit's +outcome, and why no pull request opens, if none does. It reports ``warning`` for a +hand-edit note, for a failed audit of a changed tree, or for an unset +``vars.RELEASE_APP_CLIENT_ID``. Its ``discussion`` job needs ``detect`` alone, +because ``pr`` waits for the owner's approval of ``github-release``, and the pull +request it opens is its own record. + +The category is the workflow-level ``REPORT_CATEGORY``. The API cannot create one, so it is made by hand, in Settings, Discussions. The reporting job uploads its report as an artifact, and a ``discussion`` job of @@ -1286,7 +1307,10 @@ other job keeps its permissions. It uses ``GITHUB_TOKEN`` and first-party action only. Reporting never fails a run: a missing category, a missing report or a failed API call is one warning, and the job is ``continue-on-error``, since a failed Vendor Update run is one Create Release reads as not updated. A CI timing -run failed by a HARD threshold is posted all the same, on ``always()``. +run failed by a HARD threshold is posted all the same, on ``always()``, and so is +a Python Versions run failed by its audit. The report steps of ``vendor-update``, +``conda-update`` and ``detect`` are ``continue-on-error`` on ``!cancelled()``, +and come last in each job. .. _Action Pins: diff --git a/tests/project/test_conda_platform_pin_refresh_1851.py b/tests/project/test_conda_platform_pin_refresh_1851.py index 6c69459c47..44d730beb3 100644 --- a/tests/project/test_conda_platform_pin_refresh_1851.py +++ b/tests/project/test_conda_platform_pin_refresh_1851.py @@ -771,8 +771,13 @@ def runs(step: 'dict[str, Any]') -> 'list[str]': return [line.strip() for line in str(step.get('run', '')).splitlines() if line.strip() and not line.strip().startswith('#')] + def refreshes(self, step: 'dict[str, Any]') -> 'bool': + """Whether ``step`` runs the refresh, its log kept for the run report (#2030).""" + return self.runs(step) == ['set -o pipefail', 'python util/conda_vendor.py --refresh-pins | ' + 'tee "$RUNNER_TEMP/platform-pins.log"'] + def test_the_refresh_runs_between_the_install_and_the_writer(self) -> None: - refresh = self.index(lambda step: self.runs(step) == ['python util/conda_vendor.py --refresh-pins']) + refresh = self.index(self.refreshes) install = self.index(lambda step: "python -m pip install -e '.[all]'" in self.runs(step)) writer = self.index(lambda step: 'python util/conda_vendor.py' in self.runs(step)) changed = self.index(lambda step: step.get('id') == 'verify-changed-files') @@ -785,7 +790,7 @@ def test_the_refresh_runs_between_the_install_and_the_writer(self) -> None: def test_the_job_installs_requests_before_the_refresh(self) -> None: """``.[all]`` does not bring :mod:`requests`, which ``--refresh-pins`` reads PyPI with.""" - refresh = self.index(lambda step: self.runs(step) == ['python util/conda_vendor.py --refresh-pins']) + refresh = self.index(self.refreshes) installs = [index for index, step in enumerate(self.steps) for line in self.runs(step) if 'requests' in pip_installs(line)] self.assertTrue(installs, 'no step of conda-update installs requests') diff --git a/tests/project/test_cron_reports_2030.py b/tests/project/test_cron_reports_2030.py new file mode 100644 index 0000000000..74ac939c06 --- /dev/null +++ b/tests/project/test_cron_reports_2030.py @@ -0,0 +1,502 @@ +# -*- coding: utf-8 -*- +"""#2030: the ``discussion`` jobs of :file:`cron-conda.yml` and :file:`cron-python-versions.yml`. + +Each posts its workflow's run report to that job's thread in the ``CI reports`` +category with :file:`util/discussion_report.py`, which +:mod:`tests.project.test_discussion_report_2030` tests on its own. + +* :class:`TestTheWorkflowsPost` pins both workflows: the ``discussion`` job + alone holds ``discussions: write``, every other job's permissions as they + were, first-party actions only, the mode each event maps to, the report + steps and their guards, cron-conda's job running on a failed gate but not on + a deliberate skip, and no ``workflow_run`` consumer of either. +* :class:`TestThePinRefresh` runs cron-conda's ``Refresh the platform pins`` + with a stand-in ``python``: the log it keeps, and the exit status it keeps. +* :class:`TestTheCondaReport` and :class:`TestThePythonVersionsReport` run + each ``Write the run report`` step as the runner runs it. +* :class:`TestThePostSteps` runs each ``Post the report to Discussions`` with a + stand-in ``python3``: the status it passes, and the report it hands over. + +""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import subprocess # nosec: B404 +import sys +import tempfile +import textwrap +import unittest +from typing import TYPE_CHECKING + +from tests._workflows import WORKFLOWS, load, pinned +from tests.project.test_discussion_report_2030 import (BASH, CATEGORY, FAKE_PYTHON, FIRST_PARTY, + POSTER, RUN, evaluate) +from tests.project.test_release_gates import STATUS, job_blocks, job_runs + +if TYPE_CHECKING: + from typing import Any, Optional + +CONDA = 'cron-conda.yml' +VERSIONS = 'cron-python-versions.yml' +#: ``(workflow, reporting job, the thread's title, the artifact, the file in it)``. +POSTERS = ( + (CONDA, 'conda-update', 'cron-conda: conda distribution update report', 'conda-report', 'conda-report.md'), + (VERSIONS, 'detect', 'cron-python-versions: Python version update report', 'python-versions-report', + 'python-versions-report.md'), +) +#: Every job's own ``permissions:``, and the top-level block, as before #2030. +PERMISSIONS = { + CONDA: {None: {'contents': 'read'}, 'head': {'contents': 'read'}, 'conda-update': {'contents': 'write'}, + 'conda-tag': {'contents': 'write'}, 'conda-dist': {'contents': 'write'}}, + VERSIONS: {None: {}, 'detect': {'contents': 'read', 'pull-requests': 'read'}, 'pr': {'actions': 'read'}}, +} +#: The ``--mode`` and ``--status`` each workflow passes, and the step's ``env:`` +#: beside ``GH_TOKEN``. +ARGS = { + CONDA: ('--mode "$MODE" --status "$status"', { + 'MODE': "${{ github.event_name == 'workflow_run' && 'push' || 'scheduled' }}", + 'HEAD_RESULT': '${{ needs.head.result }}', + 'UPDATE_RESULT': '${{ needs.conda-update.result }}', + 'UPDATE_STATUS': '${{ needs.conda-update.outputs.status }}', + 'TAG_RESULT': '${{ needs.conda-tag.result }}', + 'DIST_RESULT': '${{ needs.conda-dist.result }}', + }), + VERSIONS: ('--mode scheduled --status "$status"', { + 'DETECT_RESULT': '${{ needs.detect.result }}', + 'DETECT_STATUS': '${{ needs.detect.outputs.status }}', + }), +} +#: Each ``discussion`` job's ``needs:`` and ``if:``. +NEEDS = {CONDA: ['head', 'conda-update', 'conda-tag', 'conda-dist'], VERSIONS: ['detect']} +IFS = { + CONDA: "${{ always() && (needs.conda-update.result != 'skipped' || needs.head.result == 'failure' || " + "needs.head.result == 'cancelled') }}", + VERSIONS: "${{ always() && needs.detect.result != 'skipped' }}", +} +REPORT_STEPS = ['Write the run report', 'Upload the run report'] + + +def step(name: 'str', job: 'str', label: 'str') -> 'dict[str, Any]': + """The one step named ``label`` of job ``job`` in workflow ``name``.""" + [found] = [entry for entry in load(name)['jobs'][job]['steps'] if entry.get('name') == label] + return found + + +def launcher(directory: 'pathlib.Path', name: 'str', script: 'Optional[str]' = None) -> 'None': + """``directory/name`` running this interpreter, on ``script`` if one is given.""" + target = '' if script is None else f' "$(dirname "$0")/{script}"' + path = directory / name + path.write_text(f'#!/bin/sh\nexec \'{sys.executable}\'{target} "$@"\n', encoding='utf-8') + path.chmod(0o755) + + +class TestTheWorkflowsPost(unittest.TestCase): + """The ``discussion`` job of each workflow, and every other job left as it was.""" + + def test_only_the_posting_job_may_write_discussions(self) -> None: + for name, permissions in PERMISSIONS.items(): + spec = load(name) + with self.subTest(workflow=name): + self.assertEqual(spec['permissions'], permissions[None]) + self.assertEqual(sorted(spec['jobs']), sorted([*filter(None, permissions), 'discussion'])) + self.assertEqual(spec['jobs']['discussion']['permissions'], POSTER) + for job_id, expected in permissions.items(): + if job_id is None: + continue + with self.subTest(job=job_id): + self.assertEqual(spec['jobs'][job_id].get('permissions'), expected) + self.assertNotIn('discussions', json.dumps(spec['jobs'][job_id])) + + def test_the_posting_job_runs_first_party_actions_only(self) -> None: + for name, *_ in POSTERS: + job = load(name)['jobs']['discussion'] + self.assertNotIn('environment', job) + for entry in job['steps']: + if 'uses' not in entry: + continue + with self.subTest(workflow=name, step=entry.get('name') or entry['uses']): + self.assertTrue(any(pinned(entry['uses'], action) for action in FIRST_PARTY), entry['uses']) + if pinned(entry['uses'], 'actions/checkout'): + self.assertIs(entry['with']['persist-credentials'], False) + + def test_each_workflow_posts_its_report_with_the_script(self) -> None: + for name, reporter, title, artifact, file in POSTERS: + spec = load(name) + job = spec['jobs']['discussion'] + with self.subTest(workflow=name): + self.assertEqual(spec['env'], {'REPORT_CATEGORY': CATEGORY}) + self.assertEqual(job['needs'], NEEDS[name]) + self.assertEqual(' '.join(job['if'].split()), IFS[name]) + self.assertIs(job['continue-on-error'], True) + self.assertEqual(job['runs-on'], 'ubuntu-latest') + self.assertTrue(title.startswith(name[:-len('.yml')] + ': '), title) + + [upload] = [entry for entry in spec['jobs'][reporter]['steps'] + if entry.get('name') == 'Upload the run report'] + self.assertTrue(pinned(upload['uses'], 'actions/upload-artifact')) + self.assertEqual(upload['with']['name'], artifact) + self.assertEqual(upload['with']['path'], '${{ runner.temp }}/' + file) + + [download] = [entry for entry in job['steps'] if pinned(entry.get('uses'), 'actions/download-artifact')] + self.assertEqual(download['with'], {'name': artifact, 'path': 'report'}) + self.assertIs(download['continue-on-error'], True) + + post = step(name, 'discussion', 'Post the report to Discussions') + self.assertIs(post['continue-on-error'], True) + args, env = ARGS[name] + self.assertEqual(post['env'], {'GH_TOKEN': '${{ github.token }}', **env}) + command = ' '.join(post['run'][post['run'].index(RUN):].replace('\\\n', ' ').split()) + self.assertEqual(command, f'{RUN} --category "$REPORT_CATEGORY" --title \'{title}\' ' + f'--report report/{file} {args}') + + def test_the_mode_follows_the_event(self) -> None: + """Python Versions is always ``scheduled``; Conda Update's ``workflow_run`` follows a push, so ``push``.""" + self.assertEqual(sorted(load(VERSIONS)['on']), ['schedule', 'workflow_dispatch']) + self.assertEqual(sorted(load(CONDA)['on']), ['schedule', 'workflow_run']) + expression = step(CONDA, 'discussion', 'Post the report to Discussions')['env']['MODE'] + for event, mode in (('schedule', 'scheduled'), ('workflow_run', 'push')): + with self.subTest(event=event): + self.assertEqual(evaluate(expression, {'github': {'event_name': event}}), mode) + + def test_the_reporting_jobs_hand_on_their_status(self) -> None: + conda = load(CONDA)['jobs']['conda-update']['outputs'] + self.assertEqual(conda['status'], '${{ steps.run-report.outputs.status }}') + self.assertEqual(load(VERSIONS)['jobs']['detect']['outputs'], { + 'changed': '${{ steps.update.outputs.changed }}', 'publish': '${{ steps.update.outputs.publish }}', + 'status': '${{ steps.run-report.outputs.status }}'}) + + def test_the_report_steps_come_last_and_never_fail_the_job(self) -> None: + for name, reporter, *_ in POSTERS: + steps = load(name)['jobs'][reporter]['steps'] + with self.subTest(workflow=name): + self.assertEqual([entry.get('name') for entry in steps][-2:], REPORT_STEPS) + self.assertEqual(steps[-2]['id'], 'run-report') + for entry in steps[-2:]: + self.assertEqual(entry['if'], '${{ !cancelled() }}') + self.assertIs(entry['continue-on-error'], True) + + def test_no_other_job_runs_the_script(self) -> None: + for name, *_ in POSTERS: + for job_id, job in load(name)['jobs'].items(): + if job_id == 'discussion': + continue + with self.subTest(workflow=name, job=job_id): + self.assertNotIn('discussion_report', json.dumps(job)) + + def test_the_conda_report_runs_on_a_failed_gate_but_not_a_deliberate_skip(self) -> None: + """As cron-vendor's: a pull request's ``workflow_run``, or a verdict for another commit, posts nothing.""" + jobs = job_blocks((WORKFLOWS / CONDA).read_text(encoding='utf-8')) + # `(event, head, conda-update, whether discussion runs)`; tag and upload skip. + rows = ( + ('schedule', 'success', 'success', True), + ('schedule', 'success', 'failure', True), + ('schedule', 'failure', 'skipped', True), + ('schedule', 'cancelled', 'skipped', True), + ('workflow_run', 'success', 'success', True), + ('workflow_run', 'failure', 'skipped', True), + ('workflow_run', 'success', 'skipped', False), + ('workflow_run', 'skipped', 'skipped', False), + ) + for event, head, update, expected in rows: + needs = {'head': {'result': head}, 'conda-update': {'result': update, 'outputs': {}}, + 'conda-tag': {'result': 'skipped'}, 'conda-dist': {'result': 'skipped'}} + context = {STATUS: {'cancelled': False}, 'github': {'event_name': event, 'event': {}}, + 'needs': needs} # type: dict[str, Any] + with self.subTest(event=event, head=head, conda_update=update): + self.assertEqual(job_runs(jobs, 'discussion', context), expected) + [download] = [entry for entry in load(CONDA)['jobs']['discussion']['steps'] + if pinned(entry.get('uses'), 'actions/download-artifact')] + self.assertEqual(download['if'], "needs.conda-update.result != 'skipped'") + + def test_the_versions_report_does_not_wait_on_the_approval(self) -> None: + """``pr`` waits on the owner's approval of ``github-release``, so ``discussion`` does not need it.""" + jobs = load(VERSIONS)['jobs'] + self.assertEqual(jobs['pr']['environment'], 'github-release') + self.assertNotIn('pr', jobs['discussion']['needs']) + + def test_no_workflow_run_consumer_reads_either(self) -> None: + """A new job cannot move a conclusion something else filters on: nothing listens for these runs.""" + names = {load(name)['name'] for name in (CONDA, VERSIONS)} + self.assertEqual(names, {'Conda Update', 'Python Versions'}) + for path in sorted(WORKFLOWS.glob('*.yml')): + on = load(path.name).get('on') or {} + listed = (on.get('workflow_run') or {}).get('workflows', []) if isinstance(on, dict) else [] + with self.subTest(workflow=path.name): + self.assertFalse(names & set(listed), listed) + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestThePinRefresh(unittest.TestCase): + """``Refresh the platform pins``, run with a stand-in ``python``, as ``bash -e`` runs it.""" + + def run_step(self, code: 'int') -> 'tuple[subprocess.CompletedProcess[str], str]': + with tempfile.TemporaryDirectory(prefix='pcapkit-2030-pins-') as tmp: + root = pathlib.Path(tmp) + (root / 'fake.py').write_text(textwrap.dedent(f'''\ + import sys + assert sys.argv[1:] == ['util/conda_vendor.py', '--refresh-pins'], sys.argv + print('::notice title=Platform pins::pyshark 0.6 -> 0.7 on osx-arm64') + sys.exit({code}) + '''), encoding='utf-8') + launcher(root, 'python', 'fake.py') + script = root / 'step.sh' + script.write_text(step(CONDA, 'conda-update', 'Refresh the platform pins')['run'], encoding='utf-8') + env = {'PATH': f'{root}{os.pathsep}{os.environ.get("PATH", "")}', 'RUNNER_TEMP': str(root)} + result = subprocess.run([str(BASH), '-e', str(script)], cwd=root, env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + return result, (root / 'platform-pins.log').read_text(encoding='utf-8') + + def test_the_log_keeps_the_annotations(self) -> None: + result, log = self.run_step(0) + self.assertEqual(result.returncode, 0, result.stderr) + line = '::notice title=Platform pins::pyshark 0.6 -> 0.7 on osx-arm64\n' + self.assertEqual((result.stdout, log), (line, line)) + + def test_the_scripts_failure_still_fails_the_step(self) -> None: + self.assertEqual(self.run_step(3)[0].returncode, 3) + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class _ReportStep(unittest.TestCase): + """A ``Write the run report`` step, run in a scratch directory as ``bash -e`` runs it.""" + + workflow = '' + job = '' + file = '' + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-2030-report-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.root = pathlib.Path(tmp.name) + self.work = self.root / 'work' + self.temp = self.root / 'runner-temp' + self.bin = self.root / 'bin' + for path in (self.work, self.temp, self.bin): + path.mkdir() + launcher(self.bin, 'python3') + self.output = self.root / 'output' + self.script = self.root / 'step.sh' + self.script.write_text(step(self.workflow, self.job, 'Write the run report')['run'], encoding='utf-8') + + def report(self, **env: 'str') -> 'tuple[str, str]': + """The report, and the ``status`` the step wrote to ``GITHUB_OUTPUT``.""" + self.output.write_text('', encoding='utf-8') + env = dict(env, PATH=f'{self.bin}{os.pathsep}{os.environ.get("PATH", "")}', RUNNER_TEMP=str(self.temp), + GITHUB_OUTPUT=str(self.output)) + result = subprocess.run([str(BASH), '-e', str(self.script)], cwd=self.work, env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + text = (self.temp / self.file).read_text(encoding='utf-8') + self.assertEqual(result.stdout, text) + lines = self.output.read_text(encoding='utf-8').splitlines() + self.assertEqual(len(lines), 1, lines) + key, _, status = lines[0].partition('=') + self.assertEqual(key, 'status') + return text, status + + +class TestTheCondaReport(_ReportStep): + """``conda-update``'s report: the pins, the changed files, and the build pushed.""" + + workflow, job, file = CONDA, 'conda-update', 'conda-report.md' + + def setUp(self) -> None: + super().setUp() + (self.work / 'conda').mkdir() + (self.work / 'conda' / 'build').write_text('4\n', encoding='utf-8') + + def pins(self, *lines: 'str') -> 'None': + (self.temp / 'platform-pins.log').write_text(''.join(f'{line}\n' for line in lines), encoding='utf-8') + + def test_a_quiet_run(self) -> None: + self.pins('pcapkit/_extern: 12 distributions') + self.assertEqual(self.report(FILES_CHANGED='false', PUSH='skipped'), ( + '### Conda Update\n\n- Platform pins: none moved, and none warned\n' + '- Changed: nothing, so no build was bumped\n', 'success')) + + def test_a_pushed_bump_with_a_moved_pin(self) -> None: + self.pins('::notice title=Platform pins::pyshark 0.6 -> 0.7 on osx-arm64, 100%25 sure%0Areview by 2027-01') + sha = 'f4f94bcad' + '0' * 31 + self.assertEqual(self.report(FILES_CHANGED='true', CHANGED_FILES='conda/build conda/platform-pins.toml', + PUSH='success', COMMIT=sha), ( + '### Conda Update\n\n- Platform pins:\n' + ' - notice: pyshark 0.6 -> 0.7 on osx-arm64, 100% sure review by 2027-01\n' + '- Changed: conda/build conda/platform-pins.toml\n' + f'- Pushed: build 4 as {sha}\n', 'success')) + + def test_a_pin_warning_warns(self) -> None: + self.pins('::warning title=Platform pins::PyPI could not be read for pyshark (503), so its pins stay') + text, status = self.report(FILES_CHANGED='false', PUSH='skipped') + self.assertIn(' - warning: PyPI could not be read for pyshark (503), so its pins stay\n', text) + self.assertEqual(status, 'warning') + + def test_a_bump_not_pushed(self) -> None: + self.pins() + for push, line in (('success', "- Pushed: nothing; Push Changes' notice says why\n"), + ('failure', '- Pushed: nothing, as Push Changes failed\n'), + ('', '- Pushed: nothing, as the job stopped first\n')): + with self.subTest(push=push): + text, status = self.report(FILES_CHANGED='true', CHANGED_FILES='conda/build', PUSH=push) + self.assertTrue(text.endswith(line), text) + self.assertEqual(status, 'success') + + def test_a_job_that_stopped_before_the_refresh(self) -> None: + self.assertEqual(self.report(), ( + '### Conda Update\n\n- Platform pins: not refreshed, as the job stopped first\n' + '- Pushed: nothing, as the job stopped first\n', 'success')) + + +#: A plan as ``update_python_versions.py update --out`` writes it, cut to the keys the report reads. +PLAN = { + 'changed_files': ['.github/python-versions.toml', 'README.md'], + 'changes': [{'impl': 'cpython', 'version': '3.16', 'before': None, + 'after': {'tier': 'experimental', 'runner': 'ubuntu-24.04'}, 'reason': 'beta 1 is out'}, + {'impl': 'cpython', 'version': '3.10', 'before': {'tier': 'supported'}, 'after': {'tier': 'ktlo'}, + 'reason': 'end of life'}], + 'latest': ['3.14', '3.15'], + 'required': {'remove': ['Python 3.10'], 'add': ['Python 3.10 (KTLO)']}, + 'notes': [{'kind': 'held', 'text': '3.17 is a prerelease'}], +} + + +class TestThePythonVersionsReport(_ReportStep): + """``detect``'s report, from the plan ``Recompute the tiers`` wrote.""" + + workflow, job, file = VERSIONS, 'detect', 'python-versions-report.md' + #: A run that proposes a change, for each test to vary. + PROPOSED = {'UPDATE': 'success', 'CHANGED': 'true', 'PUBLISH': 'true', 'AUDIT': 'success', 'APP': 'true', + 'ON_MAIN': 'true'} + + def plan(self, **changes: 'Any') -> 'None': + (self.temp / 'proposal').mkdir(exist_ok=True) + (self.temp / 'proposal' / 'plan.json').write_text(json.dumps(dict(PLAN, **changes)), encoding='utf-8') + + def test_a_proposal(self) -> None: + self.plan() + self.assertEqual(self.report(**self.PROPOSED), ( + '### Python Versions\n\n- Tiers moved:\n' + ' - `3.16`: -- -> experimental on `ubuntu-24.04` (beta 1 is out)\n' + ' - `3.10`: supported -> KTLO (end of life)\n' + ' - `latest`: 3.14 -> 3.15\n' + '- Regenerated: `.github/python-versions.toml`, `README.md`\n' + '- Ruleset 23497679, by hand: remove `Python 3.10`\n' + '- Ruleset 23497679, by hand: add `Python 3.10 (KTLO)`\n' + '- held: 3.17 is a prerelease\n' + '- Audit: success\n' + '- Pull request: the `pr` job opens or updates it once `github-release` is approved\n', 'success')) + + def test_a_quiet_week(self) -> None: + self.plan(changed_files=[], changes=[], latest=['3.14', '3.14'], required={'remove': [], 'add': []}, + notes=[]) + self.assertEqual(self.report(**{**self.PROPOSED, 'CHANGED': 'false', 'PUBLISH': 'false'}), ( + '### Python Versions\n\n- Tiers: unchanged\n- Audit: success\n' + '- Pull request: none needed, as nothing changed\n', 'success')) + + def test_what_asks_for_a_hand_warns(self) -> None: + manual = [{'kind': 'manual', 'text': 'README.md: no row for 3.16'}] + for label, notes, env, line in ( + ('a manual note', manual, {}, '- manual: README.md: no row for 3.16\n'), + ('a failed audit', [], {'AUDIT': 'failure'}, '- Audit: failure\n'), + ('no App', [], {'APP': 'false'}, '- Pull request: none, as `vars.RELEASE_APP_CLIENT_ID` is unset\n'), + ): + with self.subTest(label): + self.plan(notes=notes) + text, status = self.report(**{**self.PROPOSED, **env}) + self.assertIn(line, text) + self.assertEqual(status, 'warning') + + def test_why_no_pull_request_opens(self) -> None: + self.plan() + for env, line in (({'PUBLISH': 'false'}, 'none, as `bot/python-versions` already proposes these tiers'), + ({'UPDATE': 'failure', 'PUBLISH': 'false'}, 'none, as Recompute the tiers failed'), + ({'ON_MAIN': 'false'}, 'none, as this run is not on `main`')): + with self.subTest(**env): + text, status = self.report(**{**self.PROPOSED, **env}) + self.assertTrue(text.endswith(f'- Pull request: {line}\n'), text) + self.assertEqual(status, 'success') + + def test_a_job_that_stopped_before_the_plan(self) -> None: + self.assertEqual(self.report(UPDATE='failure'), ( + '### Python Versions\n\n- Tiers: not computed, as the job stopped first\n- Audit: not run\n' + '- Pull request: none, as Recompute the tiers failed\n', 'success')) + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestThePostSteps(unittest.TestCase): + """Each ``Post the report to Discussions``, run with a stand-in ``python3``.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-2030-post-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.root = pathlib.Path(tmp.name) + self.bin = self.root / 'bin' + self.bin.mkdir() + (self.bin / 'fake_python.py').write_text(FAKE_PYTHON, encoding='utf-8') + launcher(self.bin, 'python3', 'fake_python.py') + self.log = self.root / 'python.json' + + def run_step(self, name: 'str', artifact: 'Optional[str]', **env: 'str') -> 'tuple[str, str]': + """The ``--status`` the step passed, and the report it handed over.""" + _, _, title, _, file = next(poster for poster in POSTERS if poster[0] == name) + work = self.root / 'work' + shutil.rmtree(work, ignore_errors=True) + (work / 'report').mkdir(parents=True) + if artifact is not None: + (work / 'report' / file).write_text(artifact, encoding='utf-8') + script = self.root / 'step.sh' + script.write_text(step(name, 'discussion', 'Post the report to Discussions')['run'], encoding='utf-8') + env = dict(env, PATH=f'{self.bin}{os.pathsep}{os.environ.get("PATH", "")}', FAKE_PYTHON_LOG=str(self.log), + REPORT_CATEGORY=CATEGORY) + result = subprocess.run([str(BASH), '-e', str(script)], cwd=work, env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + logged = json.loads(self.log.read_text(encoding='utf-8')) + args = logged['args'] + self.assertEqual(args[:7], ['util/discussion_report.py', '--category', CATEGORY, '--title', title, + '--report', f'report/{file}']) + self.assertEqual(args[7:9], ['--mode', env.get('MODE', 'scheduled')]) + self.assertEqual(args[9], '--status') + return args[10], logged['report'] + + def test_a_failed_conda_gate_posts_a_report_of_its_own(self) -> None: + for head in ('failure', 'cancelled'): + with self.subTest(head=head): + self.assertEqual(self.run_step(CONDA, None, MODE='push', HEAD_RESULT=head, UPDATE_RESULT='skipped', + TAG_RESULT='skipped', DIST_RESULT='skipped'), (head, ( + '### Conda Update\n\n' + f'- Gate: {head}, so conda-update did not run and nothing was built or pushed\n'))) + + def test_the_conda_status_and_the_tag_and_upload(self) -> None: + # `(conda-update, its status, conda-tag, conda-dist, --status, lines added)`. + tagged = '- Tag: success\n- Upload: ' + for update, status, tag, dist, expected, added in ( + ('success', 'success', 'skipped', 'skipped', 'success', ''), + ('success', 'warning', 'skipped', 'skipped', 'warning', ''), + ('success', '', 'skipped', 'skipped', 'success', ''), + ('success', 'success', 'success', 'success', 'success', tagged + 'success\n'), + ('success', 'warning', 'success', 'failure', 'failure', tagged + 'failure\n'), + ('success', 'success', 'failure', 'skipped', 'failure', '- Tag: failure\n- Upload: skipped\n'), + ('success', 'warning', 'success', 'cancelled', 'cancelled', tagged + 'cancelled\n'), + ('failure', 'warning', 'skipped', 'skipped', 'failure', ''), + ('cancelled', '', 'skipped', 'skipped', 'cancelled', ''), + ): + with self.subTest(update=update, status=status, tag=tag, dist=dist): + self.assertEqual(self.run_step(CONDA, 'the run\n', MODE='scheduled', HEAD_RESULT='success', + UPDATE_RESULT=update, UPDATE_STATUS=status, TAG_RESULT=tag, + DIST_RESULT=dist), (expected, 'the run\n' + added)) + + def test_the_versions_status(self) -> None: + for result, status, expected in (('success', 'success', 'success'), ('success', 'warning', 'warning'), + ('success', '', 'success'), ('failure', 'warning', 'failure'), + ('cancelled', '', 'cancelled')): + with self.subTest(result=result, status=status): + self.assertEqual(self.run_step(VERSIONS, 'the week\n', DETECT_RESULT=result, DETECT_STATUS=status), + (expected, 'the week\n')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_python_versions_update_1660.py b/tests/project/test_python_versions_update_1660.py index bd520a8007..f0ebe4e69f 100644 --- a/tests/project/test_python_versions_update_1660.py +++ b/tests/project/test_python_versions_update_1660.py @@ -1406,7 +1406,8 @@ def test_permissions_are_nothing_by_default_and_little_per_job(self) -> None: # detect reads the branch's pull request; pr's token is the App's. self.assertEqual(self.jobs['detect']['permissions'], {'contents': 'read', 'pull-requests': 'read'}) self.assertEqual(self.jobs['pr']['permissions'], {'actions': 'read'}) - self.assertEqual(set(self.jobs), {'detect', 'pr'}) + # #2030: `discussion` posts detect's report; test_cron_reports_2030.py pins it. + self.assertEqual(set(self.jobs), {'detect', 'pr', 'discussion'}) def test_detect_reads_no_secret_and_names_no_environment(self) -> None: text = json.dumps(self.jobs['detect']) @@ -1431,7 +1432,8 @@ def test_detect_recomputes_audits_and_hands_over(self) -> None: self.assertEqual(command[:4], ['python', '-m', 'unittest', '-v']) self.assertEqual(tuple(command[4:]), module.AUDIT) self.assertEqual(self.jobs['detect']['outputs'], {'changed': '${{ steps.update.outputs.changed }}', - 'publish': '${{ steps.update.outputs.publish }}'}) + 'publish': '${{ steps.update.outputs.publish }}', + 'status': '${{ steps.run-report.outputs.status }}'}) upload = self.step('detect', 'Upload the proposal') self.assertEqual(upload['if'], "${{ steps.update.outputs.publish == 'true' }}") # The proposal's files/.github/python-versions.toml is a hidden path. diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 0af90b7dbe..5e1dbf42e6 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -1627,6 +1627,9 @@ def job_runs(jobs: 'dict[str, str]', name: 'str', context: 'dict[str, object]') #: graph is renamed or extended, rather than leaving a new job unchecked. #: ``head`` is the call to :data:`HEAD_WORKFLOW`, and has no ``if:`` of its own. CONDA_JOBS = ('head', 'conda-update', 'conda-tag', 'conda-dist') +#: The job posting the run's report (#2030). It gates nothing, so no row is +#: keyed on it; :file:`tests/project/test_cron_reports_2030.py` pins it. +CONDA_REPORTER = 'discussion' #: The jobs of :data:`HEAD_WORKFLOW`, which a row can be keyed on as well (#1967). HEAD_JOBS = ('unit-tests', 'verdict') #: The jobs whose own ``if:`` a row decides. @@ -3798,7 +3801,7 @@ def _pre_fix_jobs(self) -> 'dict[str, str]': def test_the_scan_found_the_jobs(self) -> None: """A scanner that finds nothing would pass every test below it.""" self.assertEqual( - sorted(self.jobs), sorted(CONDA_JOBS), + sorted(self.jobs), sorted(CONDA_JOBS + (CONDA_REPORTER,)), 'the job list in cron-conda.yml moved; if that is intended, the ' 'expectations in this class need revisiting rather than this line ' 'being updated alone',