diff --git a/.github/workflows/swift-package-ci.yml b/.github/workflows/swift-package-ci.yml index 0541d42..2a5e7b7 100644 --- a/.github/workflows/swift-package-ci.yml +++ b/.github/workflows/swift-package-ci.yml @@ -57,6 +57,10 @@ jobs: matrix = config['ci'] if not matrix.get('include'): raise SystemExit('The CI matrix must have at least one entry') + for entry in matrix['include']: + timeout = entry.setdefault('timeout_minutes', 45) + if type(timeout) is not int or not 1 <= timeout <= 360: + raise SystemExit('CI timeout_minutes must be an integer from 1 to 360') with open(os.environ['GITHUB_OUTPUT'], 'a') as output: print('matrix=' + json.dumps(matrix, separators=(',', ':')), file=output) print('command=' + config['check_command'], file=output) @@ -67,7 +71,7 @@ jobs: needs: configure name: ${{ matrix.name }} runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: ${{ matrix.timeout_minutes }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} diff --git a/Documentation/Reference/WorkflowIntegration.md b/Documentation/Reference/WorkflowIntegration.md index b6831ac..ab89dfa 100644 --- a/Documentation/Reference/WorkflowIntegration.md +++ b/Documentation/Reference/WorkflowIntegration.md @@ -9,6 +9,9 @@ its bundle identifier selects the same compiler for `swift`, `xcrun` and `xcodebuild`. That Xcode still owns the Darwin SDK. Validate compiler and SDK compatibility together. Use a format-only entry with a consistent formatter toolchain, and compiler-check entries for each supported compiler. All entries must pass for acceptance. +Each entry may set `timeout_minutes` to an integer from 1 to 360; omitted values +use 45 minutes. The package owns this execution budget for its validation workload. +Exceeding the budget fails the job and the aggregate validation result. `swift-package-ci.yml` accepts a configuration path and optional source ref. It checks out one commit, runs the declared matrix with read permissions, preserves diff --git a/Tests/test_matrix.py b/Tests/test_matrix.py new file mode 100644 index 0000000..9b60f5e --- /dev/null +++ b/Tests/test_matrix.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 WITH Swift-exception +"""Exercise the reusable workflow's package-owned execution budgets.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class MatrixTests(unittest.TestCase): + def setUp(self): + fixtures = ROOT / '.build/test-fixtures' + fixtures.mkdir(parents=True, exist_ok=True) + temporary = tempfile.TemporaryDirectory(dir=fixtures) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + workflow = (ROOT / '.github/workflows/swift-package-ci.yml').read_text() + configure = workflow.split('- id: configuration', 1)[1] + self.program = textwrap.dedent(configure.split("python3 - <<'PY'\n", 1)[1].split(' PY\n', 1)[0]) + + def configure(self, entries): + configuration = self.root / 'release.json' + output = self.root / 'output' + output.write_text('') + configuration.write_text(json.dumps({'check_command': 'Scripts/check', 'ci': {'include': entries}})) + result = subprocess.run( + [sys.executable, '-c', self.program], cwd=ROOT, text=True, + capture_output=True, + env={**os.environ, 'CONFIGURATION': str(configuration), 'GITHUB_OUTPUT': str(output)}, + ) + values = dict(line.split('=', 1) for line in output.read_text().splitlines()) + return result, values + + def test_omitted_budget_and_per_entry_overrides(self): + entries = [ + {'name': 'default', 'runner': 'ubuntu-24.04'}, + {'name': 'long', 'runner': 'macos-26', 'timeout_minutes': 180}, + {'name': 'short', 'runner': 'ubuntu-24.04', 'timeout_minutes': 1}, + {'name': 'maximum', 'runner': 'ubuntu-24.04', 'timeout_minutes': 360}, + ] + result, values = self.configure(entries) + self.assertEqual(result.returncode, 0, result.stderr) + matrix = json.loads(values['matrix'])['include'] + self.assertEqual([entry['timeout_minutes'] for entry in matrix], [45, 180, 1, 360]) + self.assertEqual([entry['name'] for entry in matrix], [entry['name'] for entry in entries]) + self.assertEqual(values['command'], 'Scripts/check') + + def test_invalid_budget_fails_before_emitting_runnable_matrix(self): + for timeout in (0, -1, 361, 1.5, '180', True, None): + with self.subTest(timeout=timeout): + result, values = self.configure([{'name': 'invalid', 'timeout_minutes': timeout}]) + self.assertNotEqual(result.returncode, 0) + self.assertIn('timeout_minutes must be an integer from 1 to 360', result.stderr) + self.assertNotIn('matrix', values) + + +if __name__ == '__main__': + unittest.main()