diff --git a/.github/workflows/aurora-enhancements.yml b/.github/workflows/aurora-enhancements.yml index a8dd00bfe..1f60b9ce7 100644 --- a/.github/workflows/aurora-enhancements.yml +++ b/.github/workflows/aurora-enhancements.yml @@ -3,6 +3,7 @@ name: Aurora enhancement candidate on: pull_request: paths: + - "mseries/ci/**" - "mseries/aurora/candidate.py" - "mseries/aurora/release.py" - "mseries/aurora/release.json" @@ -22,8 +23,8 @@ permissions: contents: read concurrency: - group: aurora-enhancements-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: @@ -37,11 +38,10 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install native build tools + timeout-minutes: 20 run: | - sudo apt-get update - sudo apt-get install -y bc bison build-essential cpio flex libelf-dev libssl-dev \ - pahole clang-22 llvm-22 lld-22 libclang-22-dev bindgen zstd device-tree-compiler kmod rsync - rustup toolchain install 1.93.1 --profile minimal --component rust-src --component rustfmt + sudo --preserve-env=GITHUB_ACTIONS python3 mseries/ci/install_aurora_tools.py + timeout --kill-after=15s 300 rustup toolchain install 1.93.1 --profile minimal --component rust-src --component rustfmt echo /usr/lib/llvm-22/bin >> "$GITHUB_PATH" - name: Prepare pinned sources and patches env: diff --git a/.github/workflows/aurora-kernel.yml b/.github/workflows/aurora-kernel.yml index a88cae14c..ab738f9bb 100644 --- a/.github/workflows/aurora-kernel.yml +++ b/.github/workflows/aurora-kernel.yml @@ -12,6 +12,11 @@ on: permissions: contents: read +# New commits replace only checks for this workflow and PR. Publishing is not preempted. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: package: runs-on: ubuntu-26.04-arm diff --git a/.github/workflows/message-pins.yml b/.github/workflows/message-pins.yml index 5a2d74ffe..1335b3528 100644 --- a/.github/workflows/message-pins.yml +++ b/.github/workflows/message-pins.yml @@ -6,6 +6,11 @@ on: - mseries/phone-setup/** - mseries/install-phone-setup.sh - .github/workflows/message-pins.yml +# New commits replace only checks for this workflow and PR. Publishing is not preempted. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux-import: name: Native pin import and private keyring (Linux) diff --git a/.github/workflows/mseries-debs.yml b/.github/workflows/mseries-debs.yml index 2811d9038..461c59c76 100644 --- a/.github/workflows/mseries-debs.yml +++ b/.github/workflows/mseries-debs.yml @@ -61,8 +61,8 @@ permissions: contents: read concurrency: - group: mseries-debs-${{ github.ref }} - cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: # stable for main and for a release PR into main; dev for everything else. diff --git a/.github/workflows/mseries-diskspace-app.yml b/.github/workflows/mseries-diskspace-app.yml index b67b59057..b07b94a61 100644 --- a/.github/workflows/mseries-diskspace-app.yml +++ b/.github/workflows/mseries-diskspace-app.yml @@ -24,8 +24,8 @@ permissions: contents: read concurrency: - group: mseries-diskspace-app-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: app: diff --git a/.github/workflows/mseries-updates-app.yml b/.github/workflows/mseries-updates-app.yml index dd5db19d3..2c16ed765 100644 --- a/.github/workflows/mseries-updates-app.yml +++ b/.github/workflows/mseries-updates-app.yml @@ -29,8 +29,8 @@ permissions: contents: read concurrency: - group: mseries-updates-app-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: app: diff --git a/.github/workflows/proposed-9.0.yml b/.github/workflows/proposed-9.0.yml index 64677b808..6c5c712df 100644 --- a/.github/workflows/proposed-9.0.yml +++ b/.github/workflows/proposed-9.0.yml @@ -13,8 +13,8 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: call-reusable-build: diff --git a/.github/workflows/validate-mseries.yml b/.github/workflows/validate-mseries.yml index 44499185c..3ed0bf553 100644 --- a/.github/workflows/validate-mseries.yml +++ b/.github/workflows/validate-mseries.yml @@ -25,6 +25,11 @@ on: - "docs/local-build-status.html" - ".github/workflows/**" +# New commits replace only checks for this workflow and PR. Publishing is not preempted. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: teams-web: name: Teams desktop launcher contracts diff --git a/.github/workflows/verification-codes.yml b/.github/workflows/verification-codes.yml index b28371514..509144bba 100644 --- a/.github/workflows/verification-codes.yml +++ b/.github/workflows/verification-codes.yml @@ -6,6 +6,11 @@ on: - mseries/phone-setup/*code*.py - mseries/install-verification-codes.sh - .github/workflows/verification-codes.yml +# New commits replace only checks for this workflow and PR. Publishing is not preempted. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: codes: name: Private native field insertion and shortcut preservation (arm64) diff --git a/.github/workflows/workflow-policy.yml b/.github/workflows/workflow-policy.yml new file mode 100644 index 000000000..7d5816bba --- /dev/null +++ b/.github/workflows/workflow-policy.yml @@ -0,0 +1,38 @@ +name: Workflow cancellation policy + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'mseries/ci/**' + push: + branches: [dev] + paths: + - '.github/workflows/**' + - 'mseries/ci/**' + workflow_dispatch: + +permissions: + contents: read + +# New commits replace only checks for this workflow and PR. Publishing is not preempted. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + policy: + name: Verify superseded PR cancellation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7.1.0 + with: + python-version: '3.12' + - name: Install YAML parser + run: python3 -m pip install --disable-pip-version-check -r mseries/ci/requirements.txt + - name: Check all workflows and regression cases + run: | + python3 mseries/ci/workflow_policy.py + python3 -B -m unittest discover -s mseries/ci -p 'test_*.py' -v diff --git a/mseries/OS9-HANDOFF.md b/mseries/OS9-HANDOFF.md index 77084a0af..8c0856574 100644 --- a/mseries/OS9-HANDOFF.md +++ b/mseries/OS9-HANDOFF.md @@ -1,3 +1,38 @@ +## Bounded Aurora CI dependency downloads — 2026-10-22 + +The unified72 check spent 47m17s fetching 144 MB from the Azure Ubuntu +mirror at 50.9 kB/s, before kernel compilation. Aurora CI now first uses the +official HTTPS Ubuntu ports archive, then HTTPS Azure as a fallback. Metadata +fetches are limited to 60 seconds and package downloads to 240 seconds per +mirror. Packages retain normal Ubuntu signature verification. Installation +starts only after a successful download and uses `--no-download`; the timeout +does not interrupt dpkg to switch mirrors. Runner source files are not modified. +Rustup has a five-minute bound and the dependency step a 20-minute bound. + +Validation: 17 CI regression tests passed, including slow/erroring mirrors, +failed metadata, no installation after failed downloads, preserved signed +sources, host execution refusal, and workflow wiring. Full GitHub kernel build +and package inspection still need to pass on the updated PR head before merge. +No desktop installation, kernel change or service restart. + +## Superseded PR CI cancellation — 2026-10-22 + +Source-only: all ten PR workflows now group by workflow name plus PR number +(with branch ref as the non-PR fallback), cancelling in-progress runs only for +`pull_request` events. This prevents old revisions of one PR from accumulating +runner jobs across the shared account, as happened while Rupert awaited deployment. +Different PRs/workflows stay independent; pushes/manual runs are not preempted. +The package publisher keeps its global `mseries-apt-publish` lock and release-only +workflows retain their existing policy. A lightweight workflow checks the policy +on every workflow change, including future PR workflows. + +Validation: nine regression tests passed, including removal/misconfiguration +mutations for every PR workflow, duplicate workflow names, group isolation, and +release/publisher lock preservation. No desktop install or service restart. +Next: merge into dev after CI; existing branches need a new PR run using this +policy. Already-running legacy checks are not retroactively cancelled. This +removes superseded-run backlog, not contention from legitimate current builds. + ## Signal and Messages: INCOMPLETE / DEFERRED — 2026-10-22 Owner explicitly deferred both apps and rejected the current combined launcher diff --git a/mseries/ROADMAP.md b/mseries/ROADMAP.md index 53f385e9e..7e73f6e4d 100644 --- a/mseries/ROADMAP.md +++ b/mseries/ROADMAP.md @@ -1,3 +1,38 @@ +## Bounded Aurora CI dependency downloads — 2026-10-22 + +The unified72 check spent 47m17s fetching 144 MB from the Azure Ubuntu +mirror at 50.9 kB/s, before kernel compilation. Aurora CI now first uses the +official HTTPS Ubuntu ports archive, then HTTPS Azure as a fallback. Metadata +fetches are limited to 60 seconds and package downloads to 240 seconds per +mirror. Packages retain normal Ubuntu signature verification. Installation +starts only after a successful download and uses `--no-download`; the timeout +does not interrupt dpkg to switch mirrors. Runner source files are not modified. +Rustup has a five-minute bound and the dependency step a 20-minute bound. + +Validation: 17 CI regression tests passed, including slow/erroring mirrors, +failed metadata, no installation after failed downloads, preserved signed +sources, host execution refusal, and workflow wiring. Full GitHub kernel build +and package inspection still need to pass on the updated PR head before merge. +No desktop installation, kernel change or service restart. + +## Superseded PR CI cancellation — 2026-10-22 + +Source-only: all ten PR workflows now group by workflow name plus PR number +(with branch ref as the non-PR fallback), cancelling in-progress runs only for +`pull_request` events. This prevents old revisions of one PR from accumulating +runner jobs across the shared account, as happened while Rupert awaited deployment. +Different PRs/workflows stay independent; pushes/manual runs are not preempted. +The package publisher keeps its global `mseries-apt-publish` lock and release-only +workflows retain their existing policy. A lightweight workflow checks the policy +on every workflow change, including future PR workflows. + +Validation: nine regression tests passed, including removal/misconfiguration +mutations for every PR workflow, duplicate workflow names, group isolation, and +release/publisher lock preservation. No desktop install or service restart. +Next: merge into dev after CI; existing branches need a new PR run using this +policy. Already-running legacy checks are not retroactively cancelled. This +removes superseded-run backlog, not contention from legitimate current builds. + ## Signal and Messages: INCOMPLETE / DEFERRED — 2026-10-22 Owner explicitly deferred both apps and rejected the current combined launcher diff --git a/mseries/ci/README.md b/mseries/ci/README.md new file mode 100644 index 000000000..02ead2ab5 --- /dev/null +++ b/mseries/ci/README.md @@ -0,0 +1,48 @@ +# PR workflow cancellation + +Every workflow triggered by `pull_request` uses a workflow-scoped, PR-scoped +concurrency group. New revisions cancel the whole older run, including queued +jobs and its matrix. Different workflows and PRs do not cancel each other. +Cancellation is conditional on the PR event: manual and publishing runs keep +serialization without being interrupted. The package publisher's separate +cross-branch lock remains unchanged. + +Run the same checks as CI: + +```sh +python3 -m pip install -r mseries/ci/requirements.txt +python3 mseries/ci/workflow_policy.py +python3 -B -m unittest discover -s mseries/ci -p 'test_*.py' -v +``` + +The policy checks every PR workflow, so adding a new workflow without cancellation +fails. It also requires unique workflow names because the name is part of the +concurrency group. Regression tests deliberately remove or misconfigure policies +and check group isolation and preservation of publisher/release serialization. + +This takes effect for runs using the updated workflow revision. It does not +cancel existing legacy runs retroactively or reserve runner capacity for Rupert; +current builds still share the GitHub account's runner capacity. GitHub's +[concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) +describes the cancellation semantics used here. + +For a live cancellation check, record the current PR run IDs, then push a newer +commit to the same PR. Confirm the older active runs finish as `cancelled` and +replacement runs refer to the new head. Record another PR's run before and after +to check isolation. A manual package run on the test branch with `publish=false` +can serve as the non-PR control: a PR push must not cancel it. Never enable +publishing just to test cancellation. The regression suite separately checks +that the publishing job excludes PR events and retains its non-cancelling global +lock. Stop an unfinished synthetic manual control after recording the result. + +## Aurora dependency downloads + +`install_aurora_tools.py` runs only as root on an ephemeral Ubuntu ARM64 GitHub +runner. It uses a temporary signed Ubuntu source configuration, trying the +official HTTPS ports archive before HTTPS Azure. Existing runner sources and +third-party repositories are untouched. Each mirror gets 60 seconds for metadata +and 240 seconds for a download-only install, with connection timeouts and limited +retries. Failure switches mirrors before dpkg begins. Once verified archives are +ready, installation runs with `--no-download`. Both mirror failures stop CI. +Normal Ubuntu archive signature verification remains enabled. Rustup and the +overall workflow dependency step have separate time limits. diff --git a/mseries/ci/install_aurora_tools.py b/mseries/ci/install_aurora_tools.py new file mode 100644 index 000000000..336b8f033 --- /dev/null +++ b/mseries/ci/install_aurora_tools.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Bounded, signed Ubuntu ARM64 dependency downloads for ephemeral CI runners.""" +import os +from pathlib import Path +import re +import subprocess +import tempfile + +MIRRORS = ('https://ports.ubuntu.com/ubuntu-ports', 'https://azure.archive.ubuntu.com/ubuntu') +PACKAGES = ('bc bison build-essential cpio flex libelf-dev libssl-dev pahole clang-22 llvm-22 ' + 'lld-22 libclang-22-dev bindgen zstd device-tree-compiler kmod rsync').split() + + +def sources(codename, mirror): + if not re.fullmatch(r'[a-z][a-z0-9]+', codename) or mirror not in MIRRORS: + raise ValueError('Unsupported Ubuntu source') + return (f'Types: deb\nURIs: {mirror}\nSuites: {codename} {codename}-updates ' + f'{codename}-backports {codename}-security\nComponents: main restricted universe multiverse\n' + 'Architectures: arm64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n') + + +def invoke(command): + print('+ ' + ' '.join(command), flush=True) + return subprocess.run(command, stdin=subprocess.DEVNULL, check=False, + env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'}).returncode + + +def install(codename, architecture, root, run=invoke): + if architecture != 'arm64': + raise ValueError('This installer is only for Ubuntu ARM64 CI') + root = Path(root) + root.chmod(0o755) # APT's unprivileged downloader must traverse its scratch directory. + source_dir = root / 'sources' + source_dir.mkdir() + lists = root / 'lists' + lists.mkdir() + options = ['-o', 'Dir::Etc::sourcelist=/dev/null', '-o', f'Dir::Etc::sourceparts={source_dir}', + '-o', f'Dir::State::lists={lists}', '-o', 'Acquire::Retries=1', + '-o', 'Acquire::http::Timeout=30', '-o', 'Acquire::https::Timeout=30', + '-o', 'APT::Update::Error-Mode=any', '-o', 'Dpkg::Use-Pty=0'] + apt = ['apt-get', *options] + for mirror in MIRRORS: + (source_dir / 'ubuntu.sources').write_text(sources(codename, mirror)) + print(f'Downloading signed dependencies from {mirror}', flush=True) + if run(['timeout', '--kill-after=15s', '60', *apt, 'update']) != 0: + continue + # Bound the download, not dpkg: a slow mirror must never strand an install. + if run(['timeout', '--kill-after=15s', '240', *apt, '--download-only', '--no-install-recommends', + 'install', '-y', *PACKAGES]) != 0: + continue + if run([*apt, '--no-download', '--no-install-recommends', 'install', '-y', *PACKAGES]) != 0: + raise RuntimeError('Package installation failed after verified download') + return + raise RuntimeError('Both Ubuntu mirrors failed or exceeded the download time budget') + + +def main(): + if os.environ.get('GITHUB_ACTIONS') != 'true' or os.geteuid() != 0: + raise RuntimeError('Run as root only on an ephemeral GitHub Actions runner') + release = dict(line.split('=', 1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line) + if release.get('ID', '').strip('"') != 'ubuntu': + raise RuntimeError('Ubuntu runner required') + architecture = subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip() + with tempfile.TemporaryDirectory(prefix='aurora-apt-') as folder: + install(release['VERSION_CODENAME'].strip('"'), architecture, folder) + + +if __name__ == '__main__': + main() diff --git a/mseries/ci/requirements.txt b/mseries/ci/requirements.txt new file mode 100644 index 000000000..f62ce0c56 --- /dev/null +++ b/mseries/ci/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/mseries/ci/test_aurora_tools.py b/mseries/ci/test_aurora_tools.py new file mode 100644 index 000000000..12ad21594 --- /dev/null +++ b/mseries/ci/test_aurora_tools.py @@ -0,0 +1,82 @@ +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import install_aurora_tools as tools +import workflow_policy + + +class AuroraDownloadsTest(unittest.TestCase): + def execute(self, codes): + calls = [] + mirrors = [] + with tempfile.TemporaryDirectory() as root: + def run(command): + calls.append(command) + mirrors.append((Path(root) / 'sources/ubuntu.sources').read_text()) + return codes.pop(0) + tools.install('resolute', 'arm64', root, run) + return calls, mirrors + + def test_primary_success_downloads_before_install_and_never_uses_network_for_install(self): + calls, mirrors = self.execute([0, 0, 0]) + self.assertEqual(3, len(calls)) + self.assertIn('https://ports.ubuntu.com/ubuntu-ports', mirrors[0]) + self.assertIn('--download-only', calls[1]) + self.assertIn('--no-download', calls[2]) + self.assertEqual('apt-get', calls[2][0]) # dpkg is not killed by a download timeout. + self.assertNotIn('timeout', calls[2]) + self.assertTrue(all('--allow-unauthenticated' not in call for call in calls)) + + def test_slow_download_times_out_then_uses_fallback(self): + calls, mirrors = self.execute([0, 124, 0, 0, 0]) + self.assertEqual(['timeout', '--kill-after=15s', '60'], calls[0][:3]) + self.assertEqual(['timeout', '--kill-after=15s', '240'], calls[1][:3]) + self.assertIn('https://azure.archive.ubuntu.com/ubuntu', mirrors[2]) + self.assertIn('--no-download', calls[-1]) + self.assertIn('Acquire::https::Timeout=30', calls[0]) + + def test_failed_metadata_uses_fallback_and_both_failures_prevent_install(self): + calls, mirrors = self.execute([100, 0, 0, 0]) + self.assertIn('https://azure.archive.ubuntu.com/ubuntu', mirrors[1]) + with self.assertRaisesRegex(RuntimeError, 'Both Ubuntu mirrors'): + self.execute([124, 124]) + + def test_both_download_timeouts_never_start_dpkg(self): + run = mock.Mock(side_effect=[0, 124, 0, 124]) + with tempfile.TemporaryDirectory() as folder, self.assertRaisesRegex(RuntimeError, 'Both Ubuntu mirrors'): + tools.install('resolute', 'arm64', folder, run) + self.assertEqual(4, run.call_count) + self.assertTrue(all('--no-download' not in call.args[0] for call in run.call_args_list)) + + def test_main_refuses_to_install_on_developer_host(self): + with mock.patch.dict(tools.os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, 'ephemeral GitHub'): + tools.main() + + def test_install_failure_is_not_retried_as_a_mirror_failure(self): + with self.assertRaisesRegex(RuntimeError, 'Package installation failed'): + self.execute([0, 0, 100]) + + def test_signed_sources_preserve_release_and_reject_unexpected_architecture_or_mirror(self): + source = tools.sources('resolute', tools.MIRRORS[0]) + self.assertIn('resolute resolute-updates resolute-backports resolute-security', source) + self.assertIn('Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg', source) + self.assertNotIn('Trusted:', source) + for codename, mirror in [('resolute\nTrusted: yes', tools.MIRRORS[0]), ('resolute', 'https://untrusted.invalid')]: + with self.assertRaises(ValueError): + tools.sources(codename, mirror) + with tempfile.TemporaryDirectory() as folder, self.assertRaises(ValueError): + tools.install('resolute', 'amd64', folder, mock.Mock()) + + def test_workflow_uses_installer_and_bounds_rustup(self): + workflow = workflow_policy.load(workflow_policy.ROOT / '.github/workflows/aurora-enhancements.yml') + step = next(s for s in workflow['jobs']['build']['steps'] if s.get('name') == 'Install native build tools') + self.assertIn('install_aurora_tools.py', step['run']) + self.assertIn('timeout --kill-after=15s 300 rustup', step['run']) + self.assertEqual('20', step['timeout-minutes']) + self.assertIn('mseries/ci/**', workflow['on']['pull_request']['paths']) + + +if __name__ == '__main__': + unittest.main() diff --git a/mseries/ci/test_workflow_policy.py b/mseries/ci/test_workflow_policy.py new file mode 100644 index 000000000..8a7df3145 --- /dev/null +++ b/mseries/ci/test_workflow_policy.py @@ -0,0 +1,86 @@ +import copy +from pathlib import Path +import tempfile +import unittest + +import workflow_policy as policy + + +class WorkflowCancellationTest(unittest.TestCase): + def setUp(self): + self.workflows = policy.repository_workflows() + self.pr_workflows = [(p, w) for p, w in self.workflows if 'pull_request' in w.get('on', {})] + + def test_every_real_pr_workflow_is_covered(self): + self.assertGreaterEqual(len(self.pr_workflows), 9) + self.assertEqual([], policy.check(self.workflows)) + + def test_removing_cancellation_from_any_workflow_fails(self): + for path, workflow in self.pr_workflows: + with self.subTest(path=path): + changed = copy.deepcopy(workflow) + changed.pop('concurrency') + self.assertTrue(policy.check([(path, changed)])) + + def test_ref_only_groups_and_non_cancelling_pr_runs_fail(self): + for field, value in [('group', '${{ github.ref }}'), ('cancel-in-progress', 'false'), + ('cancel-in-progress', 'true'), ('group', '${{ github.event.pull_request.number }}')]: + with self.subTest(field=field, value=value): + changed = copy.deepcopy(self.pr_workflows[0][1]) + changed['concurrency'][field] = value + self.assertTrue(policy.check([('bad.yml', changed)])) + + def test_new_pr_workflow_cannot_escape_policy(self): + new = {'name': 'New expensive checks', 'on': {'pull_request': {}}, 'jobs': {}} + self.assertTrue(policy.check(self.workflows + [('new.yml', new)])) + + def test_duplicate_names_cannot_cancel_another_workflow(self): + for name in [self.pr_workflows[0][1]['name'], self.pr_workflows[0][1]['name'].swapcase()]: + with self.subTest(name=name): + duplicate = copy.deepcopy(self.pr_workflows[0][1]) + duplicate['name'] = name + self.assertTrue(policy.check(self.workflows + [('duplicate.yml', duplicate)])) + + def test_same_pr_shares_group_but_other_prs_workflows_and_refs_do_not(self): + # Substitute the documented GitHub contexts in the actual checked expression. + def group(workflow, pr, ref): + return policy.GROUP.replace('${{ github.workflow }}', workflow).replace( + '${{ github.event.pull_request.number || github.ref }}', str(pr) if pr else ref) + first = group('Validation', 526, 'refs/pull/526/merge') + self.assertEqual(first, group('Validation', 526, 'refs/pull/526/merge')) + self.assertNotEqual(first, group('Validation', 527, 'refs/pull/527/merge')) + self.assertNotEqual(first, group('Messages', 526, 'refs/pull/526/merge')) + self.assertNotEqual(first, group('Validation', None, 'refs/heads/dev')) + self.assertNotEqual(group('Packages', None, 'refs/heads/dev'), + group('Packages', None, 'refs/heads/main')) + self.assertEqual("${{ github.event_name == 'pull_request' }}", policy.CANCEL) + + def test_release_workflows_and_publisher_lock_are_preserved(self): + workflows = dict(self.workflows) + publisher = workflows['.github/workflows/mseries-debs.yml']['jobs']['publish']['concurrency'] + self.assertEqual({'group': 'mseries-apt-publish', 'cancel-in-progress': 'false'}, publisher) + self.assertEqual('false', workflows['.github/workflows/daily-9.0.yml']['concurrency']['cancel-in-progress']) + self.assertEqual('false', workflows['.github/workflows/upstream-watch.yml']['concurrency']['cancel-in-progress']) + + def test_ci_executes_policy_and_regressions_and_prs_cannot_publish(self): + workflows = dict(self.workflows) + guard = workflows['.github/workflows/workflow-policy.yml'] + commands = '\n'.join(step.get('run', '') for step in guard['jobs']['policy']['steps']) + self.assertIn('python3 mseries/ci/workflow_policy.py', commands) + self.assertIn('unittest discover -s mseries/ci', commands) + self.assertIn('.github/workflows/**', guard['on']['pull_request']['paths']) + publisher = workflows['.github/workflows/mseries-debs.yml']['jobs']['publish'] + self.assertEqual("github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish)", + ' '.join(publisher['if'].split())) + + def test_yaml_loader_preserves_on_and_boolean_scalars(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / 'sample.yml' + path.write_text('on:\n pull_request:\nconcurrency:\n cancel-in-progress: false\n') + data = policy.load(path) + self.assertIn('pull_request', data['on']) + self.assertEqual('false', data['concurrency']['cancel-in-progress']) + + +if __name__ == '__main__': + unittest.main() diff --git a/mseries/ci/workflow_policy.py b/mseries/ci/workflow_policy.py new file mode 100644 index 000000000..b45d95d4d --- /dev/null +++ b/mseries/ci/workflow_policy.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Keep superseded PR checks from consuming the account's runner capacity.""" +from pathlib import Path +import sys + +import yaml + +GROUP = "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}" +CANCEL = "${{ github.event_name == 'pull_request' }}" +ROOT = Path(__file__).resolve().parents[2] + + +def load(path): + # BaseLoader preserves GitHub's `on` key (YAML 1.1 treats it as a boolean). + return yaml.load(path.read_text(), Loader=yaml.BaseLoader) + + +def check(workflows): + errors = [] + names = set() + for path, workflow in workflows: + triggers = workflow.get('on', {}) + if 'pull_request' not in triggers: + continue + name = workflow.get('name') + normalized = name.casefold() if isinstance(name, str) else None + if not normalized or normalized in names: + errors.append(f'{path}: PR workflow names must be present and unique') + names.add(normalized) + policy = workflow.get('concurrency', {}) + if not isinstance(policy, dict) or policy.get('group') != GROUP: + errors.append(f'{path}: concurrency must isolate workflow and PR, falling back to ref') + if not isinstance(policy, dict) or policy.get('cancel-in-progress') != CANCEL: + errors.append(f'{path}: only superseded pull_request runs may be cancelled') + return errors + + +def repository_workflows(): + paths = sorted((ROOT / '.github/workflows').glob('*.y*ml')) + return [(str(path.relative_to(ROOT)), load(path)) for path in paths] + + +def main(): + errors = check(repository_workflows()) + for error in errors: + print(error, file=sys.stderr) + if not errors: + print('All PR workflows isolate and cancel superseded PR runs.') + return bool(errors) + + +if __name__ == '__main__': + sys.exit(main())