Skip to content

Commit 8babcf4

Browse files
committed
ci: gate jobs by relevant paths
1 parent 815146f commit 8babcf4

2 files changed

Lines changed: 253 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,21 @@ concurrency:
2626
cancel-in-progress: true
2727

2828
jobs:
29-
native-changes:
30-
name: Detect native-relevant changes
31-
if: github.event_name == 'push'
29+
changes:
30+
name: Detect relevant changes
3231
runs-on: ubuntu-latest
3332
outputs:
34-
required: ${{ steps.paths.outputs.required }}
33+
android_native: ${{ steps.paths.outputs.android_native }}
34+
macos: ${{ steps.paths.outputs.macos }}
35+
macos_native: ${{ steps.paths.outputs.macos_native }}
36+
mobile: ${{ steps.paths.outputs.mobile }}
37+
source_package: ${{ steps.paths.outputs.source_package }}
38+
video: ${{ steps.paths.outputs.video }}
39+
windows: ${{ steps.paths.outputs.windows }}
3540

3641
steps:
3742
- name: Check out repository
43+
if: github.event_name != 'workflow_dispatch'
3844
uses: actions/checkout@v7
3945
with:
4046
fetch-depth: 0
@@ -45,21 +51,63 @@ jobs:
4551
env:
4652
BEFORE_SHA: ${{ github.event.before }}
4753
CURRENT_SHA: ${{ github.sha }}
54+
EVENT_NAME: ${{ github.event_name }}
55+
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
56+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
4857
run: |
49-
if [[ "$BEFORE_SHA" =~ ^0+$ ]]; then
50-
changed="$(git show --pretty=format: --name-only "$CURRENT_SHA")"
58+
domains=(
59+
android_native
60+
macos
61+
macos_native
62+
mobile
63+
source_package
64+
video
65+
windows
66+
)
67+
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
68+
for domain in "${domains[@]}"; do
69+
echo "$domain=false" >> "$GITHUB_OUTPUT"
70+
done
71+
exit 0
72+
fi
73+
74+
if [[ "$EVENT_NAME" == "pull_request" ]]; then
75+
base="$PR_BASE_SHA"
76+
head="$PR_HEAD_SHA"
5177
else
52-
changed="$(git diff --name-only "$BEFORE_SHA" "$CURRENT_SHA")"
78+
base="$BEFORE_SHA"
79+
head="$CURRENT_SHA"
5380
fi
54-
if grep -Eq '^(mobile/|tools/freshloop/|tools/mac_env/|freshloop$|freshloop\.cmd$|mac_env$)' <<<"$changed"; then
55-
echo "required=true" >> "$GITHUB_OUTPUT"
81+
82+
if [[ "$base" =~ ^0+$ ]]; then
83+
changed="$(git ls-tree -r --name-only "$head")"
5684
else
57-
echo "required=false" >> "$GITHUB_OUTPUT"
85+
changed="$(git diff --name-only "$base" "$head")"
5886
fi
87+
printf '%s\n' "$changed"
88+
89+
set_output() {
90+
local name="$1"
91+
local pattern="$2"
92+
if grep -Eq "$pattern" <<<"$changed"; then
93+
echo "$name=true" >> "$GITHUB_OUTPUT"
94+
else
95+
echo "$name=false" >> "$GITHUB_OUTPUT"
96+
fi
97+
}
98+
99+
set_output mobile '^(\.github/workflows/ci\.yml$|mobile/)'
100+
set_output windows '^(\.github/workflows/ci\.yml$|tools/freshloop/|freshloop$|freshloop\.cmd$)'
101+
set_output macos '^(\.github/workflows/ci\.yml$|tools/(freshloop|mac_env)/|freshloop$|mac_env$)'
102+
set_output video '^(\.github/workflows/ci\.yml$|video/)'
103+
set_output source_package '^(\.github/workflows/ci\.yml$|mobile/|tools/(freshloop|mac_env)/|tools/(package_t22|validate_t22_package|test_package_t22|test_ci_path_gates)\.py$|freshloop$|freshloop\.cmd$|mac_env$|README\.md$|CONTRIBUTING\.md$|LICENSE$|THIRD_PARTY_NOTICES\.md$|docs/demo-script\.md$|submission/SUBMISSION-CONTENTS\.md$)'
104+
set_output android_native '^(\.github/workflows/ci\.yml$|mobile/)'
105+
set_output macos_native '^(\.github/workflows/ci\.yml$|mobile/|tools/(freshloop|mac_env)/|freshloop$|mac_env$)'
59106
60107
toolchain-contracts-windows:
61108
name: Windows toolchain contracts
62-
if: github.event_name != 'workflow_dispatch'
109+
needs: changes
110+
if: github.event_name != 'workflow_dispatch' && needs.changes.outputs.windows == 'true'
63111
runs-on: windows-latest
64112
timeout-minutes: 10
65113

@@ -81,7 +129,8 @@ jobs:
81129

82130
verify-mobile:
83131
name: Typecheck, lint, and test
84-
if: github.event_name != 'workflow_dispatch'
132+
needs: changes
133+
if: github.event_name != 'workflow_dispatch' && needs.changes.outputs.mobile == 'true'
85134
runs-on: ubuntu-latest
86135
timeout-minutes: 20
87136
defaults:
@@ -117,10 +166,54 @@ jobs:
117166
- name: Export iOS bundle
118167
run: npm run export:ios
119168

169+
verify-video:
170+
name: Typecheck and test Remotion video
171+
needs: changes
172+
if: github.event_name != 'workflow_dispatch' && needs.changes.outputs.video == 'true'
173+
runs-on: ubuntu-latest
174+
timeout-minutes: 10
175+
defaults:
176+
run:
177+
working-directory: video
178+
179+
steps:
180+
- name: Check out repository
181+
uses: actions/checkout@v7
182+
183+
- name: Set up Node.js
184+
uses: actions/setup-node@v7
185+
with:
186+
node-version: 24.14.0
187+
cache: npm
188+
cache-dependency-path: video/package-lock.json
189+
190+
- name: Install dependencies
191+
run: npm ci
192+
193+
- name: Typecheck
194+
run: npm run check
195+
196+
- name: Test video contracts
197+
run: npm run test:video
198+
199+
source-package-contracts:
200+
name: Source-package and workflow contracts
201+
needs: changes
202+
if: github.event_name != 'workflow_dispatch' && needs.changes.outputs.source_package == 'true'
203+
runs-on: ubuntu-latest
204+
timeout-minutes: 5
205+
206+
steps:
207+
- name: Check out repository
208+
uses: actions/checkout@v7
209+
210+
- name: Test source-package and CI path contracts
211+
run: python3 -m unittest -v tools/test_package_t22.py tools/test_ci_path_gates.py
212+
120213
android-release:
121214
name: Android single-ABI release build
122-
needs: native-changes
123-
if: always() && ((github.event_name == 'push' && needs.native-changes.outputs.required == 'true') || (github.event_name == 'workflow_dispatch' && inputs.android_i18n))
215+
needs: changes
216+
if: always() && ((github.event_name == 'push' && needs.changes.outputs.android_native == 'true') || (github.event_name == 'workflow_dispatch' && inputs.android_i18n))
124217
runs-on: ubuntu-latest
125218
timeout-minutes: 30
126219

@@ -204,7 +297,8 @@ jobs:
204297

205298
toolchain-contracts-macos:
206299
name: macOS toolchain contracts (${{ matrix.arch }})
207-
if: github.event_name != 'workflow_dispatch'
300+
needs: changes
301+
if: github.event_name != 'workflow_dispatch' && needs.changes.outputs.macos == 'true'
208302
runs-on: ${{ matrix.runner }}
209303
timeout-minutes: 10
210304
strategy:
@@ -247,8 +341,8 @@ jobs:
247341

248342
macos-release:
249343
name: macOS ARM native release build
250-
needs: native-changes
251-
if: always() && ((github.event_name == 'push' && needs.native-changes.outputs.required == 'true') || (github.event_name == 'workflow_dispatch' && inputs.mac_release))
344+
needs: changes
345+
if: always() && ((github.event_name == 'push' && needs.changes.outputs.macos_native == 'true') || (github.event_name == 'workflow_dispatch' && inputs.mac_release))
252346
runs-on: macos-15
253347
timeout-minutes: 35
254348

tools/test_ci_path_gates.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import unittest
5+
from pathlib import Path
6+
7+
8+
ROOT = Path(__file__).resolve().parents[1]
9+
WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml"
10+
11+
12+
class CiPathGateTests(unittest.TestCase):
13+
@classmethod
14+
def setUpClass(cls) -> None:
15+
cls.workflow_text = WORKFLOW.read_text(encoding="utf-8")
16+
17+
def job(self, name: str) -> str:
18+
match = re.search(
19+
rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-z0-9-]+:\n|\Z)",
20+
self.workflow_text,
21+
)
22+
self.assertIsNotNone(match, f"missing CI job: {name}")
23+
return match.group(1)
24+
25+
def test_change_classifier_exposes_every_job_domain(self) -> None:
26+
changes = self.job("changes")
27+
outputs = set(
28+
re.findall(
29+
r"^ ([a-z_]+): \$\{\{ steps\.paths\.outputs\.[a-z_]+ \}\}$",
30+
changes,
31+
flags=re.MULTILINE,
32+
)
33+
)
34+
35+
self.assertEqual(
36+
{
37+
"android_native",
38+
"macos",
39+
"macos_native",
40+
"mobile",
41+
"source_package",
42+
"video",
43+
"windows",
44+
},
45+
outputs,
46+
)
47+
48+
def test_automatic_jobs_depend_on_their_path_domain(self) -> None:
49+
domains = {
50+
"toolchain-contracts-windows": "windows",
51+
"verify-mobile": "mobile",
52+
"verify-video": "video",
53+
"source-package-contracts": "source_package",
54+
"toolchain-contracts-macos": "macos",
55+
}
56+
57+
for job_name, domain in domains.items():
58+
with self.subTest(job=job_name):
59+
job = self.job(job_name)
60+
self.assertIn(" needs: changes\n", job)
61+
self.assertIn(
62+
f"needs.changes.outputs.{domain} == 'true'",
63+
job,
64+
)
65+
66+
def test_native_jobs_use_separate_minimal_domains(self) -> None:
67+
android = self.job("android-release")
68+
macos = self.job("macos-release")
69+
70+
self.assertIn(" needs: changes\n", android)
71+
self.assertIn(
72+
"needs.changes.outputs.android_native == 'true'",
73+
android,
74+
)
75+
self.assertIn(" needs: changes\n", macos)
76+
self.assertIn(
77+
"needs.changes.outputs.macos_native == 'true'",
78+
macos,
79+
)
80+
81+
def test_classifier_handles_pull_requests_pushes_and_manual_runs(self) -> None:
82+
script = self.job("changes")
83+
84+
for token in (
85+
"workflow_dispatch",
86+
"PR_BASE_SHA",
87+
"PR_HEAD_SHA",
88+
"BEFORE_SHA",
89+
"CURRENT_SHA",
90+
"mobile/",
91+
"tools/freshloop/",
92+
"tools/(freshloop|mac_env)/",
93+
"video/",
94+
"tools/(package_t22|validate_t22_package",
95+
r"\.github/workflows/ci\.yml",
96+
):
97+
with self.subTest(token=token):
98+
self.assertIn(token, script)
99+
100+
def test_each_path_class_enables_only_related_domains(self) -> None:
101+
patterns = dict(
102+
re.findall(
103+
r"set_output ([a-z_]+) '([^']+)'",
104+
self.job("changes"),
105+
)
106+
)
107+
cases = {
108+
"docs/report.md": set(),
109+
"mobile/src/domain/dates/date-only.ts": {
110+
"android_native",
111+
"macos_native",
112+
"mobile",
113+
"source_package",
114+
},
115+
"tools/freshloop/lib/runner.cjs": {
116+
"macos",
117+
"macos_native",
118+
"source_package",
119+
"windows",
120+
},
121+
"tools/mac_env/lib/runner.cjs": {
122+
"macos",
123+
"macos_native",
124+
"source_package",
125+
},
126+
"video/src/FreshLoopDemo.tsx": {"video"},
127+
"tools/package_t22.py": {"source_package"},
128+
".github/workflows/ci.yml": set(patterns),
129+
}
130+
131+
for path, expected in cases.items():
132+
with self.subTest(path=path):
133+
actual = {
134+
domain
135+
for domain, pattern in patterns.items()
136+
if re.search(pattern, path)
137+
}
138+
self.assertEqual(expected, actual)
139+
140+
141+
if __name__ == "__main__":
142+
unittest.main()

0 commit comments

Comments
 (0)