Skip to content

Commit 9aef7d0

Browse files
committed
ci: skip already-passing app builds via content-hash cache marker
1 parent d1af2ed commit 9aef7d0

4 files changed

Lines changed: 185 additions & 19 deletions

File tree

.github/actions/build-android-app/action.yml

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,77 @@ inputs:
99
description: Whether to run `expo prebuild --platform android` before the Gradle build
1010
required: false
1111
default: "true"
12+
filter-name:
13+
description: dorny/paths-filter filter name for this app+platform (e.g. llm-android). Used as the content-hash cache key so a previously-passing build can be skipped if nothing relevant has changed.
14+
required: true
1215

1316
runs:
1417
using: composite
1518
steps:
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v6
21+
with:
22+
node-version-file: .nvmrc
23+
cache: "yarn"
24+
25+
- name: Install root dependencies
26+
shell: bash
27+
run: yarn install --immutable
28+
29+
- name: Compute build hash
30+
id: hash
31+
shell: bash
32+
run: |
33+
h=$(node scripts/compute-app-hash.js "${{ inputs.filter-name }}")
34+
echo "key=build-${{ inputs.filter-name }}-$h" >> "$GITHUB_OUTPUT"
35+
36+
- name: Lookup pass marker
37+
id: cache
38+
uses: actions/cache/restore@v4
39+
with:
40+
path: ${{ runner.temp }}/ci-marker
41+
key: ${{ steps.hash.outputs.key }}
42+
lookup-only: true
43+
44+
- name: Skip notice
45+
if: steps.cache.outputs.cache-hit == 'true'
46+
shell: bash
47+
run: echo "Skipping build — ${{ inputs.filter-name }} already passed at this content hash."
48+
1649
- name: Free disk space
50+
if: steps.cache.outputs.cache-hit != 'true'
1751
shell: bash
1852
run: |
1953
sudo rm -rf /usr/share/dotnet
2054
sudo rm -rf /opt/ghc
2155
sudo rm -rf /opt/hostedtoolcache/CodeQL
2256
sudo docker system prune -af
2357
24-
- name: Setup Node.js
25-
uses: actions/setup-node@v6
26-
with:
27-
node-version-file: .nvmrc
28-
cache: "yarn"
29-
3058
- name: Setup Java 17
59+
if: steps.cache.outputs.cache-hit != 'true'
3160
uses: actions/setup-java@v5
3261
with:
3362
distribution: "zulu"
3463
java-version: 17
3564
cache: "gradle"
3665

37-
- name: Install root dependencies
38-
shell: bash
39-
run: yarn install --immutable
40-
4166
- name: Install Expo CLI
42-
if: inputs.expo-prebuild == 'true'
67+
if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true'
4368
shell: bash
4469
run: |
4570
npm install -g @expo/cli
4671
echo "$(npm prefix -g)/bin" >> $GITHUB_PATH
4772
4873
- name: Generate native Android project
49-
if: inputs.expo-prebuild == 'true'
74+
if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true'
5075
working-directory: ${{ inputs.app-path }}
5176
shell: bash
5277
run: |
5378
rm -rf android
5479
npx expo prebuild --platform android --no-install
5580
5681
- name: Cache Gradle
82+
if: steps.cache.outputs.cache-hit != 'true'
5783
uses: actions/cache@v5
5884
with:
5985
path: |
@@ -65,6 +91,7 @@ runs:
6591
${{ runner.os }}-gradle-
6692
6793
- name: Build app
94+
if: steps.cache.outputs.cache-hit != 'true'
6895
working-directory: ${{ inputs.app-path }}/android
6996
shell: bash
7097
run: |
@@ -76,3 +103,17 @@ runs:
76103
-PreactNativeArchitectures=arm64-v8a \
77104
-Dorg.gradle.jvmargs="-Xmx4g -XX:+HeapDumpOnOutOfMemoryError" \
78105
-Dorg.gradle.workers.max=4
106+
107+
- name: Save pass marker
108+
if: steps.cache.outputs.cache-hit != 'true' && success()
109+
shell: bash
110+
run: |
111+
mkdir -p "${{ runner.temp }}/ci-marker"
112+
touch "${{ runner.temp }}/ci-marker/passed"
113+
114+
- name: Cache pass marker
115+
if: steps.cache.outputs.cache-hit != 'true' && success()
116+
uses: actions/cache/save@v4
117+
with:
118+
path: ${{ runner.temp }}/ci-marker
119+
key: ${{ steps.hash.outputs.key }}

.github/actions/build-ios-app/action.yml

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@ inputs:
99
description: Whether to run `expo prebuild --platform ios` before pod install
1010
required: false
1111
default: "true"
12+
filter-name:
13+
description: dorny/paths-filter filter name for this app+platform (e.g. llm-ios). Used as the content-hash cache key so a previously-passing build can be skipped if nothing relevant has changed.
14+
required: true
1215

1316
runs:
1417
using: composite
1518
steps:
16-
- name: Setup Xcode
17-
uses: maxim-lobanov/setup-xcode@v1
18-
with:
19-
xcode-version: latest-stable
20-
2119
- name: Setup Node.js
2220
uses: actions/setup-node@v6
2321
with:
@@ -28,27 +26,55 @@ runs:
2826
shell: bash
2927
run: yarn install --immutable
3028

29+
- name: Compute build hash
30+
id: hash
31+
shell: bash
32+
run: |
33+
h=$(node scripts/compute-app-hash.js "${{ inputs.filter-name }}")
34+
echo "key=build-${{ inputs.filter-name }}-$h" >> "$GITHUB_OUTPUT"
35+
36+
- name: Lookup pass marker
37+
id: cache
38+
uses: actions/cache/restore@v4
39+
with:
40+
path: ${{ runner.temp }}/ci-marker
41+
key: ${{ steps.hash.outputs.key }}
42+
lookup-only: true
43+
44+
- name: Skip notice
45+
if: steps.cache.outputs.cache-hit == 'true'
46+
shell: bash
47+
run: echo "Skipping build — ${{ inputs.filter-name }} already passed at this content hash."
48+
49+
- name: Setup Xcode
50+
if: steps.cache.outputs.cache-hit != 'true'
51+
uses: maxim-lobanov/setup-xcode@v1
52+
with:
53+
xcode-version: latest-stable
54+
3155
- name: Install Expo CLI
32-
if: inputs.expo-prebuild == 'true'
56+
if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true'
3357
shell: bash
3458
run: |
3559
npm install -g @expo/cli
3660
echo "$(npm prefix -g)/bin" >> $GITHUB_PATH
3761
3862
- name: Generate native iOS project
39-
if: inputs.expo-prebuild == 'true'
63+
if: steps.cache.outputs.cache-hit != 'true' && inputs.expo-prebuild == 'true'
4064
working-directory: ${{ inputs.app-path }}
4165
shell: bash
4266
run: |
4367
rm -rf ios
4468
npx expo prebuild --platform ios --no-install
4569
4670
- name: Install CocoaPods dependencies
71+
if: steps.cache.outputs.cache-hit != 'true'
4772
working-directory: ${{ inputs.app-path }}/ios
4873
shell: bash
4974
run: pod install
5075

5176
- name: Build app
77+
if: steps.cache.outputs.cache-hit != 'true'
5278
working-directory: ${{ inputs.app-path }}/ios
5379
shell: bash
5480
run: |
@@ -65,3 +91,17 @@ runs:
6591
-jobs $(sysctl -n hw.ncpu) \
6692
COMPILER_INDEX_STORE_ENABLE=NO \
6793
ONLY_ACTIVE_ARCH=YES | xcbeautify
94+
95+
- name: Save pass marker
96+
if: steps.cache.outputs.cache-hit != 'true' && success()
97+
shell: bash
98+
run: |
99+
mkdir -p "${{ runner.temp }}/ci-marker"
100+
touch "${{ runner.temp }}/ci-marker/passed"
101+
102+
- name: Cache pass marker
103+
if: steps.cache.outputs.cache-hit != 'true' && success()
104+
uses: actions/cache/save@v4
105+
with:
106+
path: ${{ runner.temp }}/ci-marker
107+
key: ${{ steps.hash.outputs.key }}

.github/workflows/build-apps.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,23 @@ jobs:
157157
uses: actions/checkout@v6
158158
- name: Setup
159159
uses: ./.github/actions/setup
160+
- name: Compute bundle hash
161+
id: hash
162+
run: |
163+
h=$(node scripts/compute-app-hash.js "${{ matrix.app }}-app")
164+
echo "key=bundle-${{ matrix.platform }}-${{ matrix.app }}-$h" >> "$GITHUB_OUTPUT"
165+
- name: Lookup pass marker
166+
id: cache
167+
uses: actions/cache/restore@v4
168+
with:
169+
path: ${{ runner.temp }}/ci-marker
170+
key: ${{ steps.hash.outputs.key }}
171+
lookup-only: true
172+
- name: Skip notice
173+
if: steps.cache.outputs.cache-hit == 'true'
174+
run: echo "Skipping bundle — bundle-${{ matrix.platform }}-${{ matrix.app }} already passed at this content hash."
160175
- name: Bundle JS for ${{ matrix.platform }}
176+
if: steps.cache.outputs.cache-hit != 'true'
161177
working-directory: apps/${{ matrix.app }}
162178
run: |
163179
if [ "${{ matrix.app }}" = "bare-rn" ]; then
@@ -171,6 +187,17 @@ jobs:
171187
--platform ${{ matrix.platform }} \
172188
--output-dir /tmp/expo-export
173189
fi
190+
- name: Save pass marker
191+
if: steps.cache.outputs.cache-hit != 'true' && success()
192+
run: |
193+
mkdir -p "${{ runner.temp }}/ci-marker"
194+
touch "${{ runner.temp }}/ci-marker/passed"
195+
- name: Cache pass marker
196+
if: steps.cache.outputs.cache-hit != 'true' && success()
197+
uses: actions/cache/save@v4
198+
with:
199+
path: ${{ runner.temp }}/ci-marker
200+
key: ${{ steps.hash.outputs.key }}
174201

175202
build-android:
176203
needs: detect-changes
@@ -193,6 +220,7 @@ jobs:
193220
with:
194221
app-path: apps/${{ matrix.app }}
195222
expo-prebuild: ${{ matrix.app == 'bare-rn' && 'false' || 'true' }}
223+
filter-name: ${{ matrix.app }}-android
196224

197225
build-ios:
198226
needs: detect-changes
@@ -215,3 +243,4 @@ jobs:
215243
with:
216244
app-path: apps/${{ matrix.app }}
217245
expo-prebuild: ${{ matrix.app == 'bare-rn' && 'false' || 'true' }}
246+
filter-name: ${{ matrix.app }}-ios

scripts/compute-app-hash.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env node
2+
// Computes a stable content hash of every tracked file matched by a given
3+
// filter in .github/workflows/build-apps.yml. Used as a cache key in the
4+
// build-apps matrix so a previously-passing app/platform cell can be skipped
5+
// when nothing relevant has changed since.
6+
7+
const fs = require('fs');
8+
const cp = require('child_process');
9+
const crypto = require('crypto');
10+
const yaml = require('js-yaml');
11+
const picomatch = require('picomatch');
12+
13+
const filterName = process.argv[2];
14+
if (!filterName) {
15+
console.error('Usage: compute-app-hash.js <filter-name>');
16+
process.exit(1);
17+
}
18+
19+
const wf = yaml.load(
20+
fs.readFileSync('.github/workflows/build-apps.yml', 'utf8')
21+
);
22+
const filtersStr = wf.jobs['detect-changes'].steps.find(
23+
(s) => s.id === 'filter'
24+
).with.filters;
25+
const filters = yaml.load(filtersStr);
26+
27+
const flatten = (x) => (Array.isArray(x) ? x.flatMap(flatten) : [x]);
28+
const patterns = flatten(filters[filterName]).filter(
29+
(p) => typeof p === 'string'
30+
);
31+
if (patterns.length === 0) {
32+
console.error(`Unknown filter: ${filterName}`);
33+
process.exit(1);
34+
}
35+
const matchers = patterns.map((p) => picomatch(p, { dot: true }));
36+
const matchAny = (file) => matchers.some((m) => m(file));
37+
38+
// `git ls-files -s` outputs: <mode> <hash> <stage>\t<path>
39+
// The hash is a content-addressable git blob hash, so the same content always
40+
// produces the same line — the SHA256 below is stable across machines.
41+
const lines = cp
42+
.execSync('git ls-files -s', { encoding: 'utf8' })
43+
.trim()
44+
.split('\n')
45+
.filter((line) => {
46+
const tabIdx = line.indexOf('\t');
47+
return tabIdx !== -1 && matchAny(line.slice(tabIdx + 1));
48+
})
49+
.sort();
50+
51+
const sha = crypto
52+
.createHash('sha256')
53+
.update(lines.join('\n'))
54+
.digest('hex')
55+
.slice(0, 16);
56+
console.log(sha);

0 commit comments

Comments
 (0)