diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index bc570278eb..9dc0c681ab 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -136,10 +136,6 @@ 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 @@ -147,23 +143,34 @@ 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: python3 util/release_vendor_update.py + 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 version_check: name: Check Version diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3535123a10..d1bdbe71ce 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -630,9 +630,10 @@ jobs: # bound. APT_PACKAGES, the job's env, is what both the plan and the # install read, so the key always describes the set installed. # - # The plan, seed, install and collect steps run util/apt_packages.py - # (#1967); its docstring says what each subcommand does. `pypcap-parity` - # runs the same steps; every body but the install's is identical to these. + # The plan, install and collect steps run util/apt_packages.py (#1967); + # its docstring says what each subcommand does. The seed step, one copy + # and a count, stays inline. `pypcap-parity` runs the same steps; every + # body but the install's is identical to these. - name: Plan this engine's system packages id: apt-plan if: env.APT_PACKAGES != '' @@ -650,7 +651,10 @@ jobs: - name: Seed apt's archive with this engine's cached .deb files if: steps.apt-cache.outputs.cache-matched-key != '' - run: /usr/bin/python3 util/apt_packages.py seed + run: | + set -eu + sudo cp "$RUNNER_TEMP"/apt-debs/*.deb /var/cache/apt/archives/ + echo "Seeded $(find "$RUNNER_TEMP/apt-debs" -name '*.deb' | wc -l) .deb files" - name: Install this engine's system packages timeout-minutes: 10 @@ -961,7 +965,10 @@ jobs: id: apt-seed if: steps.apt-cache.outputs.cache-matched-key != '' continue-on-error: ${{ matrix.ktlo == true }} - run: /usr/bin/python3 util/apt_packages.py seed + run: | + set -eu + sudo cp "$RUNNER_TEMP"/apt-debs/*.deb /var/cache/apt/archives/ + echo "Seeded $(find "$RUNNER_TEMP/apt-debs" -name '*.deb' | wc -l) .deb files" - name: Install libpcap headers, a C toolchain, and tshark id: apt-install diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index a44622333b..b48723940f 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -1208,11 +1208,14 @@ without the script, and runs the script against a small limit. Workflow Scripts (`#1967 `__) --------------------------------------------------------------------------------- -Shell that several steps repeat lives in a :file:`util/` script, which the steps -run, rather than in a composite action: a composite action's inner steps cannot -take ``timeout-minutes``, so each calling step keeps its own, with its ``shell:``, -``if:``, ``env:`` and ``continue-on-error``. Each script's docstring says what it -does; each has tests under :file:`tests/project/` that run it. +Shell that several steps repeat, or that has real logic -- branches, or a bug +history a test should pin -- lives in a :file:`util/` script, which the steps run, +rather than in a composite action: a composite action's inner steps cannot take +``timeout-minutes``, so each calling step keeps its own, with its ``shell:``, +``if:``, ``env:`` and ``continue-on-error``. A few plain commands stay inline even +where repeated, as the apt seed steps do, since they read more easily than a call +to a script. Each script's docstring says what it does; each has tests under +:file:`tests/project/` that run it. .. list-table:: :header-rows: 1 @@ -1266,10 +1269,12 @@ does; each has tests under :file:`tests/project/` that run it. - No. * - :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 - ``apt_packages.py plan``, ``seed``, ``install`` and ``collect``. The - ``actions/cache`` steps and the AppArmor step (`#2019 - `__) stay as they were. + (and so its KTLO twin): the plan, install and collect steps, as + ``apt_packages.py plan``, ``install`` and ``collect``. The + ``actions/cache`` steps, the seed steps (``Seed apt's archive with this + engine's cached .deb files`` and ``Seed apt's archive with the cached .deb + files``) and the AppArmor step (`#2019 + `__) stay inline. - No. * - :file:`util/_actions.py` - No step: it is imported by the scripts that share its annotations, step @@ -1298,11 +1303,6 @@ does; each has tests under :file:`tests/project/` that run it. after a checkout of :file:`util/` alone (`#888 `__). - No. - * - :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 `__). - - No. * - :file:`util/release_tiers.py` - ``Read the released Python versions`` in ``create-release.yml``'s ``version_check``, on setup-python's ``python3`` (`#1660 diff --git a/tests/project/test_actions_helper_2047.py b/tests/project/test_actions_helper_2047.py index 95ac60ec59..52043aadfe 100644 --- a/tests/project/test_actions_helper_2047.py +++ b/tests/project/test_actions_helper_2047.py @@ -46,7 +46,7 @@ SCRIPTS = ('apt_packages', 'check_suite_report', 'ci_timing', 'classify_changes', 'codeql_report', 'coverage_report', 'discussion_report', 'leg_summary', 'pip_install', 'project_status', 'python_versions', 'regenerate_samples', 'release_conda', 'release_published', 'release_status', - 'release_tags', 'release_tiers', 'release_upload', 'release_vendor_update', 'superseded', + 'release_tags', 'release_tiers', 'release_upload', 'superseded', 'update_python_versions', 'vendor_code_changed', 'vendor_test_gate', 'verdict_covers_checkout') #: 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 @@ -72,7 +72,6 @@ 'release_tags': (['--help'], {}), 'release_tiers': (['--help'], {}), 'release_upload': (['--help'], {}), - 'release_vendor_update': (['--help'], {}), 'superseded': (['--help'], {}), 'update_python_versions': (['--help'], {}), 'vendor_code_changed': (['--help'], {}), diff --git a/tests/project/test_apt_packages_1967.py b/tests/project/test_apt_packages_1967.py index ef1317f449..60954d0243 100644 --- a/tests/project/test_apt_packages_1967.py +++ b/tests/project/test_apt_packages_1967.py @@ -1,18 +1,19 @@ # -*- coding: utf-8 -*- """#1967: :file:`util/apt_packages.py`, the apt steps of ``engine-tests`` and ``pypcap-parity``. -* :class:`TestPlan`, :class:`TestSeed`, :class:`TestInstall` and :class:`TestCollect` - run each subcommand in this process with :func:`subprocess.run` stubbed, so every - command it would run, and every failure of one, is checked without ``sudo`` or apt. +* :class:`TestPlan`, :class:`TestInstall` and :class:`TestCollect` run each + subcommand in this process with :func:`subprocess.run` stubbed, so every command + it would run, and every failure of one, is checked without ``sudo`` or apt. * :class:`TestTheFormerShellAgrees` runs the shell the steps held before the script, and then the steps' own ``run:``, against the same stand-in ``sudo``, ``timeout``, ``apt-get``, ``dpkg``, ``dpkg-deb``, ``dpkg-query`` and ``debconf-set-selections``, - and requires the same status, output, commands and files. -* :class:`TestTheStepsRunTheScript` pins each apt step to the script with its - ``id``, ``if:``, ``continue-on-error`` and ``timeout-minutes``, the KTLO twin to - the same steps, and the AppArmor step (#2019) to its inline shell; no other step - in any workflow keeps apt shell of its own, and #1672's scan of what a KTLO leg - may write sees the script's warnings. + and requires the same status, output, commands and files. The seed steps, which + #1967 put back inline as one copy and a count, run their own ``run:`` there. +* :class:`TestTheStepsRunTheScript` pins each apt step to the script, or the seed + steps to their shell, with its ``id``, ``if:``, ``continue-on-error`` and + ``timeout-minutes``, the KTLO twin to the same steps, and the AppArmor step + (#2019) to its inline shell; no other step in any workflow keeps apt shell of its + own, and #1672's scan of what a KTLO leg may write sees the script's warnings. The retry, the fallback mirror and the cache key are also run through the steps by :mod:`tests.project.test_unit_tests_queue` and :mod:`tests.project.test_workflow_apt_cache`. @@ -213,35 +214,6 @@ def test_a_missing_variable_is_an_error(self) -> None: self.environ[name] = 'restored' if name != 'GITHUB_OUTPUT' else str(self.output) -class TestSeed(_InProcess): - """The restored files into apt's archive with ``sudo cp``, then their count.""" - - def test_the_files_are_copied_and_counted(self) -> None: - cache = self.runner_temp / 'apt-debs' - (cache / 'nested').mkdir(parents=True) - for name in ('b.deb', 'a.deb', 'nested/c.deb', 'notes.txt'): - (cache / name).write_bytes(b'') - status, output, runner = self.main(['seed', '--archives', str(self.archives)], answers()) - self.assertEqual(status, 0) - self.assertEqual(runner.commands, [['sudo', 'cp', str(cache / 'a.deb'), str(cache / 'b.deb'), - f'{self.archives}/']]) - self.assertEqual(output, 'Seeded 3 .deb files\n') - - def test_the_default_archive_is_apts(self) -> None: - (self.runner_temp / 'apt-debs').mkdir() - (self.runner_temp / 'apt-debs' / 'a.deb').write_bytes(b'') - _, _, runner = self.main(['seed'], answers()) - self.assertEqual(runner.commands[0][-1], '/var/cache/apt/archives/') - - def test_nothing_restored_fails_on_the_pattern(self) -> None: - for code, expected in ((1, 1), (-15, 143)): - with self.subTest(code=code): - status, output, runner = self.main(['seed', '--archives', str(self.archives)], answers(cp=(code, b''))) - self.assertEqual((status, output), (expected, '')) - self.assertEqual(runner.commands, [['sudo', 'cp', str(self.runner_temp / 'apt-debs' / '*.deb'), - f'{self.archives}/']]) - - class TestInstall(_InProcess): """debconf for tshark, then up to two update-and-install attempts, the second on a fallback mirror.""" @@ -421,8 +393,8 @@ class TestArguments(_InProcess): """One subcommand, and only its own options.""" def test_refused(self) -> None: - for args in ([], ['update'], ['plan', 'extra'], ['seed', '--mirrors', 'x'], ['install', 'tshark'], - ['collect', '--name', 'x']): + for args in ([], ['update'], ['plan', 'extra'], ['seed'], ['plan', '--archives', 'x'], + ['install', 'tshark'], ['collect', '--name', 'x']): with self.subTest(args=args), contextlib.redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit) as raised: self.main(args, answers()) @@ -449,7 +421,8 @@ def test_refused(self) -> None: echo "key=${prefix}$(printf '%s\n' "$plan" | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" fi ''').lstrip() # noqa: E501 -FORMER_SEED = textwrap.dedent(r''' +#: The seed steps' ``run:``, which a ``seed`` subcommand held for a while (#1967). +SEED = textwrap.dedent(r''' set -eu sudo cp "$RUNNER_TEMP"/apt-debs/*.deb /var/cache/apt/archives/ echo "Seeded $(find "$RUNNER_TEMP/apt-debs" -name '*.deb' | wc -l) .deb files" @@ -511,7 +484,7 @@ def test_refused(self) -> None: ("Plan this engine's system packages", 'apt-plan', "env.APT_PACKAGES != ''", None, 4, f'{IMAGE_PYTHON} {SCRIPT} plan'), ("Seed apt's archive with this engine's cached .deb files", None, - "steps.apt-cache.outputs.cache-matched-key != ''", None, None, f'{IMAGE_PYTHON} {SCRIPT} seed'), + "steps.apt-cache.outputs.cache-matched-key != ''", None, None, SEED), ("Install this engine's system packages", None, None, None, 10, f'{IMAGE_PYTHON} {SCRIPT} install --name "$ENGINE"'), ("Collect this engine's installed .deb files for the cache", None, @@ -522,7 +495,7 @@ def test_refused(self) -> None: PARITY_STEPS = ( ('Plan the system packages', 'apt-plan', "env.APT_PACKAGES != ''", KTLO, 4, f'{IMAGE_PYTHON} {SCRIPT} plan'), ("Seed apt's archive with the cached .deb files", 'apt-seed', "steps.apt-cache.outputs.cache-matched-key != ''", - KTLO, None, f'{IMAGE_PYTHON} {SCRIPT} seed'), + KTLO, None, SEED), ('Install libpcap headers, a C toolchain, and tshark', 'apt-install', None, KTLO, 10, f'{IMAGE_PYTHON} {SCRIPT} install'), ('Collect the installed .deb files for the cache', None, ENGINE_STEPS[3][2], None, None, @@ -530,7 +503,7 @@ def test_refused(self) -> None: ) SITES = {'engine-tests': ENGINE_STEPS, 'pypcap-parity': PARITY_STEPS} #: Each step's former shell, by the subcommand that replaced it. -FORMER = {'plan': FORMER_PLAN, 'seed': FORMER_SEED, 'collect': FORMER_COLLECT} +FORMER = {'plan': FORMER_PLAN, 'collect': FORMER_COLLECT} APPARMOR = 'Let tshark read the checkout past its AppArmor profile' #: Text only the apt steps' former shell held. INLINE = ('apt-get', 'dpkg', 'debconf-set-selections', 'apt-mirrors', '/var/cache/apt', 'apt_install') @@ -653,6 +626,8 @@ def scenarios(self) -> 'Iterator[tuple[str, str, str, dict[str, str]]]': """``(job, step, former shell, env)`` for every apt step but the installs.""" for job, sites in SITES.items(): for name, _, _, _, _, run in sites: + if SCRIPT not in run: + continue command = run.split(f'{SCRIPT} ', 1)[1].split()[0] if command in FORMER: yield job, name, FORMER[command], {} @@ -676,23 +651,42 @@ def test_plan(self) -> None: self.assertTrue(keyed) def test_seed(self) -> None: + """The seed steps' own shell: ``sudo cp`` of the top-level files, then the count of every ``.deb``.""" + cache = self.runner_temp / 'apt-debs' + def nothing() -> None: pass def restored() -> None: - (self.runner_temp / 'apt-debs' / 'deep').mkdir(parents=True) + (cache / 'deep').mkdir(parents=True) for name in ('a.deb', 'b.deb', 'deep/c.deb', 'readme'): - (self.runner_temp / 'apt-debs' / name).write_text(name, encoding='utf-8') + (cache / name).write_text(name, encoding='utf-8') - for job, step, former, _ in self.scenarios(): - if former is not FORMER_SEED: - continue + seeds = [(job, name, run) for job, sites in SITES.items() for name, *_, run in sites if run == SEED] + self.assertEqual([job for job, _, _ in seeds], list(SITES)) + for job, step, _ in seeds: + run = next(item for item in steps(WORKFLOW, job) if item.get('name') == step)['run'] + script = run.replace('/var/cache/apt/archives', str(self.archives)) for prepare in (restored, nothing): with self.subTest(job=job, restored=prepare is restored): - old, new = self.both(job, step, former, prepare) - self.assertEqual(new, old) - self.assertEqual(sorted(path.name for path in self.archives.iterdir()), - ['a.deb', 'b.deb'] if prepare is restored else []) + self.reset() + prepare() + result = subprocess.run(['bash', '-e', '-c', script], cwd=ROOT, env=self.env(), # nosec: B603 + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, + timeout=120) + output = result.stdout.decode('utf-8', 'replace') + copied = sorted(path.name for path in self.archives.iterdir()) + if prepare is restored: + self.assertEqual((result.returncode, output, copied), + (0, 'Seeded 3 .deb files\n', ['a.deb', 'b.deb'])) + self.assertEqual(self.calls.read_text(encoding='utf-8'), + f'sudo cp {cache}/a.deb {cache}/b.deb {self.archives}/\n') + else: + # The unmatched glob stays the pattern, so `cp` fails on it and nothing is counted. + self.assertEqual((result.returncode, copied), (1, [])) + self.assertNotIn('Seeded', output) + self.assertEqual(self.calls.read_text(encoding='utf-8'), + f'sudo cp {cache}/*.deb {self.archives}/\n') def test_collect(self) -> None: def archive(broken: 'bool') -> 'Callable[[], None]': @@ -745,7 +739,7 @@ def prepare(mirrors: 'Optional[str]' = mirrors) -> None: class TestTheStepsRunTheScript(unittest.TestCase): - """Each apt step is one call of the script with its guards; no step keeps apt shell.""" + """Each apt step is one call of the script, or the seed steps' shell, with its guards; no step keeps apt shell.""" def test_each_site_runs_the_script_with_its_guards(self) -> None: for job, sites in {**SITES, 'pypcap-parity-ktlo': PARITY_STEPS}.items(): @@ -765,10 +759,10 @@ def test_each_site_runs_the_script_with_its_guards(self) -> None: self.assertEqual(positions[3], positions[2] + 2) def test_a_ktlo_leg_reads_only_warnings_from_it(self) -> None: - """#1672's scan of a step's commands sees the script's, which are warnings alone.""" + """#1672's scan of a step's commands sees the script's, which are warnings alone; the seed's, none.""" for name, *_, run in PARITY_STEPS: with self.subTest(step=name): - self.assertEqual(commands(run), {'warning'}) + self.assertEqual(commands(run), {'warning'} if SCRIPT in run else set()) self.assertLessEqual(commands(run), KTLO_COMMANDS) def test_the_ktlo_twin_runs_the_same_steps(self) -> None: @@ -794,9 +788,12 @@ def test_the_packages_come_from_the_job(self) -> None: def test_no_step_keeps_apt_shell(self) -> None: found = [] covered = {name for sites in SITES.values() for name, *_ in sites} + seeds = {name for sites in SITES.values() for name, *_, run in sites if run == SEED} for path in sorted(WORKFLOWS.glob('*.yml')): for job, spec in (load(path).get('jobs') or {}).items(): for item in spec.get('steps') or (): + if path.name == WORKFLOW and item.get('name') in seeds and item.get('run') == SEED: + continue # pinned to its shell above run = '\n'.join(line for line in str(item.get('run', '')).splitlines() if not line.lstrip().startswith('#')) found.extend(f'{path.name} {job} {item.get("name")!r}: {piece}' diff --git a/tests/project/test_discussion_report_2030.py b/tests/project/test_discussion_report_2030.py index 957eebf894..82e443e439 100644 --- a/tests/project/test_discussion_report_2030.py +++ b/tests/project/test_discussion_report_2030.py @@ -662,10 +662,7 @@ 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) - # #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') + release = (WORKFLOWS / 'create-release.yml').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_release_reads_the_vendor_update_1748.py b/tests/project/test_release_reads_the_vendor_update_1748.py index 336b4cc1f3..e210e0201f 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,9 @@ 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, :file:`util/release_vendor_update.py` since #1967, runs -here against a stand-in ``gh`` that applies the real ``--jq`` filter with ``jq``. +'false'``. Its step's inline shell runs here under ``bash -e``, as a step with no +``shell:`` runs, against a stand-in ``gh`` that applies the real ``--jq`` filter +with ``jq``. """ @@ -32,7 +33,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 script_command, workflow_steps +from tests.project.test_release_tags_the_run_sha_1634 import workflow_steps if TYPE_CHECKING: from typing import Any, Optional @@ -86,10 +87,11 @@ 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_read_scopes_only(self) -> None: - """``actions: read`` for the listing; ``contents: read`` for the checkout of its script (#1967).""" + def test_it_reads_the_triggering_runs_jobs_with_actions_read_only(self) -> None: + """``actions: read`` for the listing, and nothing else: the job checks nothing out (#1967).""" block = self.jobs['vendor_update'] - self.assertIn(' permissions:\n actions: read\n contents: read\n outputs:', block) + self.assertIn(' permissions:\n actions: read\n outputs:', block) + self.assertEqual([step.get('name') for step in self.steps['vendor_update']], [STEP]) self.assertIn('updated: ${{ steps.updated.outputs.updated }}', block) step = named(self.steps['vendor_update'], STEP) self.assertEqual(step.get('id'), 'updated') @@ -114,18 +116,11 @@ 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. - - It runs ``python3 util/release_vendor_update.py`` (#1967), here on this - interpreter. - - """ + """``vendor_update``'s step against the two real shapes and the ones it cannot read.""" @classmethod def setUpClass(cls) -> None: - 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 + cls.script = named(workflow_steps(WORKFLOW.read_text(encoding='utf-8'))['vendor_update'], STEP)['run'] def setUp(self) -> None: self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1748-')) @@ -133,6 +128,7 @@ 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]': @@ -146,8 +142,8 @@ 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(self.command, cwd=self.tmp, env=env, capture_output=True, - text=True, check=False, timeout=scale_timeout(30)) + result = subprocess.run(['bash', '-e', str(self.tmp / 'step.sh')], 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 index 2a8e1d5359..6f743281e7 100644 --- a/tests/project/test_release_status_scripts_1967.py +++ b/tests/project/test_release_status_scripts_1967.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- -"""#1967: :file:`util/release_status.py` and :file:`util/release_vendor_update.py`, run as Create Release runs them. +"""#1967: :file:`util/release_status.py`, and ``vendor_update``'s inline shell, 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. + why this run did or did not release anything" held before the script, on the + cases below, and the script on the same, and requires the same status, + standard output and error, ``$GITHUB_OUTPUT``, ``$GITHUB_STEP_SUMMARY`` and + ``gh`` calls. The former body is kept here verbatim, and the case table is + checked to reach every branch of it. +* :class:`TestTheVendorUpdateStaysInline` pins ``vendor_update``'s "Read whether + its vendor-update job ran", which #1967 moved to a script and then put back + inline as too plain to need one, to its shell verbatim, with no checkout, and + runs that ``run:`` on its own case table, which reaches each of its branches. +* :class:`TestTheStepsRunTheScripts` pins ``release_status``'s step to its + script, after a checkout of :file:`util/` alone, and the script 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 @@ -206,9 +209,9 @@ 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''' +#: "Read whether its vendor-update job ran" in ``vendor_update``: its ``run:``, +#: which a script held for a while (#1967). +VENDOR_SHELL = textwrap.dedent(r''' set -uo pipefail if ! result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${PCAPKIT_VENDOR_RUN_ID}/jobs" \ @@ -235,9 +238,10 @@ 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'}), } +#: ``vendor_update``'s one step, and its permissions: the listing's alone. +VENDOR_STEP = 'Read whether its vendor-update job ran' +VENDOR_PERMISSIONS = {'actions': 'read'} #: The checkout each job opens with: this run's commit, :file:`util/` alone. CHECKOUT = {'ref': '${{ github.sha }}', 'sparse-checkout': 'util', 'persist-credentials': False} @@ -378,7 +382,7 @@ ('neither', b'success\n', {'PCAPKIT_VENDOR_RUN_ID': UNSET, 'GITHUB_REPOSITORY': UNSET}), ) -#: Each way the former ``vendor_update`` shell ends: ``name -> (status, +#: Each way the ``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 '), @@ -451,6 +455,19 @@ def run_both(self, shell: 'str', script: 'str', env: 'dict[str, str]', """The state each leaves: :meth:`former` on ``shell``, then ``python3 util/