-
Notifications
You must be signed in to change notification settings - Fork 0
361 lines (342 loc) · 17.9 KB
/
Copy pathci.yml
File metadata and controls
361 lines (342 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
name: ci
# Everything that can be verified without a phone runs here. The phone is the
# only place the runtime can be validated, so everything else is pushed left:
# protocol unit tests, both targetSdk variants, and the runtime assembly.
#
# Building on x86_64 runners is also the *only* place an Android APK can be
# built today: Google ships AAPT2 for Linux as an x86_64 binary, so an aarch64
# host cannot run it.
on:
push:
# `main` 是发布线,`proroot-*` 是长期存在的实验线:那条线上的东西(见
# `design/ui-refactor/07-construction-decisions.md` D44)**只能在真机上验证**,
# 而真机验证需要一份能装的包 —— 所以它必须和 main 一样每次都跑,否则分支推上去
# 既没有 CI 也没有 artifact,只能靠人手点 `workflow_dispatch`。
#
# 这里故意不写 `branches: ['**']`:一次性的分支不值得每次都花掉两个 targetSdk
# 变体的完整构建(含 ~65 MB 运行时装配),而 PR 那一侧本来就覆盖了评审场景。
branches: [main, 'proroot-*']
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
protocol:
name: Protocol core (JVM unit tests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- name: Test :rpc
run: ./gradlew :rpc:test --console=plain
- name: Publish test report
if: always()
uses: actions/upload-artifact@v4
with:
name: rpc-test-report
path: rpc/build/reports/tests/test
if-no-files-found: ignore
apk:
name: APK (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Known-good sandbox: what Termux pins. Sideload via F-Droid / APK.
- variant: sideload28
targetSdk: 28
# Play-viable: proot loader is exec'd from nativeLibraryDir and maps
# the guest binaries. Must be proven on hardware before it ships.
- variant: modern36
targetSdk: 36
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-node@v4
with:
node-version: "22"
# `setup-android` 只是把 runner **已经装好**的 SDK 接上环境并接受许可;它自己
# 那次 `sdkmanager --licenses` 是整条流水线里唯一会「因为 runner 侧的仓库/网络
# 抖动而 exit 1」的步骤 —— 实测在 4cc3003 与 47129a2 两次运行里连环失败,而同一
# 批次里 5 个代码检查(注释守卫 / 纯逻辑 / 协议单测 / pi 契约)全绿。所以它改成
# **失败不致命**,后面那一步负责把环境变量补齐并自己接受许可:action 正常时是幂等
# 的空操作,action 挂掉时构建照常进行。SDK 本身缺失会在这里明确报错,不会静默
# 走到 Gradle 才炸。
- uses: android-actions/setup-android@v3
continue-on-error: true
- name: Ensure an Android SDK is on the environment
run: |
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
if [ ! -d "$SDK" ]; then
echo "::error::no Android SDK found at $SDK on this runner"
exit 1
fi
{
echo "ANDROID_HOME=$SDK"
echo "ANDROID_SDK_ROOT=$SDK"
echo "PATH=$SDK/cmdline-tools/latest/bin:$SDK/platform-tools:$PATH"
} >> "$GITHUB_ENV"
# 2>/dev/null + `|| true`:许可早就接受过时 sdkmanager 仍可能因为仓库不可达
# 而返回非零,那不该让构建失败;真缺平台时 Gradle 会用它自己的下载器去取。
yes 2>/dev/null | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
- uses: gradle/actions/setup-gradle@v4
# The runtime is assembled from pinned upstreams rather than committed:
# ~65 MB of third-party binaries stay out of git, and every byte is
# traceable to a SHA-256 in runtime.lock.json (checked by the tool).
- name: Cache upstream artifacts
uses: actions/cache@v4
with:
path: build/downloads
key: pi-runtime-downloads-${{ hashFiles('runtime.lock.json') }}
restore-keys: pi-runtime-downloads-
- name: Assemble runtime (proot, Ubuntu, Node, rg/fd, pi)
run: |
sudo apt-get install -y -qq dpkg-dev xz-utils
node tools/fetch-runtime.mjs
- name: Assemble arm64 release APK
run: |
set -o pipefail
./gradlew :app:assembleRelease \
-Ppi.targetSdk=${{ matrix.targetSdk }} \
--console=plain 2>&1 | tee /tmp/assemble.log
# A red build has to be *readable* by whoever has to fix it. The job log needs
# admin rights on the repository; `::error::` annotations do not — they are
# readable through the REST API by anyone who can see the run. Those two facts
# are why this step exists: the first red build of this batch reported only
# "Process completed with exit code 1", and the Kotlin error that caused it had
# to be reconstructed by reasoning.
- name: Report Kotlin errors as annotations
if: failure()
run: |
grep -E "^e: file://|error: " /tmp/assemble.log | head -30 | while IFS= read -r line; do
echo "::error::$line"
done
- name: Report APK size
run: |
ls -lh app/build/outputs/apk/release/
find app/build/outputs/apk -name '*.apk' -printf '%f %s bytes\n'
# The runtime the app ships is the one thing in this repository that a
# compiler cannot check: the payloads are opaque archives handed to the
# guest, and every failure mode below is silent at build time.
#
# Two of them have already shipped:
#
# 1. The Android Gradle Plugin gunzips an asset whose *file extension* is
# `gz` while merging assets (AssetItem.shouldBeUngzipped:
# Files.getFileExtension(name).equals("gz")) and renames it with
# Files.getNameWithoutExtension, so `ubuntu-base.tar.gz` (28.5 MiB)
# landed in the APK as `ubuntu-base.tar` (106 MB of uncompressed tar)
# while `RuntimeProvisioner` still asked for
# `runtime/ubuntu-base.tar.gz`. `AssetManager.open()` answered with a
# bare-path FileNotFoundException and the app never provisioned a
# runtime. Not AAPT2 - aapt2 2.20 ships both names unchanged - which is
# why the fix is the *name*, `*.tgz`, and not a flag: the rename happens
# before aapt2 ever sees the file.
#
# 2. An earlier version of this step only counted `assets/runtime/`
# entries and asked how many were `Stored`. That check passed a
# 458,824,004-byte APK whose payloads had been renamed by (1) - it
# checked neither names nor sizes. A green tick here is only worth
# what it measures.
#
# So this step compares the APK against the files that were actually staged:
# * same names, both directions - nothing renamed away (1) and nothing
# extra invented;
# * every entry `Stored`, because `AssetManager.openFd()` refuses a
# deflated entry and that is `openPayload`'s third and last access layer;
# * same byte counts. A `Stored` entry's uncompressed length *is* the file
# on disk, so this is exact equality, and it is the assertion (1) cannot
# survive: 28.5 MiB does not equal 106 MB;
# * a ceiling on the APK itself, so a future packaging accident surfaces as
# a number rather than as a bug report from a phone.
- name: Verify runtime payloads are stored uncompressed and unrecompressed
run: |
set -euo pipefail
apk="$(ls app/build/outputs/apk/release/*.apk | head -1)"
apk_bytes="$(stat -c %s "$apk")"
echo "inspecting $apk ($apk_bytes bytes)"
staged=app/src/main/assets/runtime
ls -l "$staged"
# Name, uncompressed length and method for every assets/runtime entry.
unzip -lv "$apk" \
| awk '$8 ~ /^assets\/runtime\// { print $1, $2, $8 }' \
| sort -k3 > /tmp/apk-payloads.txt
cat /tmp/apk-payloads.txt
# The same three facts for every staged file, in the same order.
for f in "$staged"/*; do
printf '%s %s %s\n' "$(stat -c %s "$f")" Stored "assets/runtime/$(basename "$f")"
done | sort -k3 > /tmp/staged-payloads.txt
echo "--- staged ---"
cat /tmp/staged-payloads.txt
fail=0
if [ "$(wc -l < /tmp/apk-payloads.txt)" -lt 5 ]; then
echo "::error::expected at least five runtime payloads in the APK, found $(wc -l < /tmp/apk-payloads.txt)"
fail=1
fi
if ! diff -u /tmp/staged-payloads.txt /tmp/apk-payloads.txt; then
echo "::error::the APK's runtime payloads are not the staged ones (name, size or method differ); a rename here is what AAPT2 does to *.gz assets"
fail=1
fi
if [ "$apk_bytes" -gt $((200 * 1024 * 1024)) ]; then
echo "::error::APK is $((apk_bytes / 1024 / 1024)) MiB, over the 200 MiB ceiling; payloads are probably being stored expanded"
fail=1
fi
# The digest the app stamps a device with, and reads back on every boot to
# decide whether the tree already unpacked matches the payloads in this APK.
# It is generated by tools/fetch-runtime.mjs; the app asks for it by the
# name in RuntimeProvisioner.REVISION_ASSET and falls back to the
# hand-written RUNTIME_REVISION when it is absent. That fallback is the bug
# this asset exists to remove, so a rename on either side - or an assembler
# that stopped writing it - has to fail here rather than on a device.
rev="$(unzip -p "$apk" assets/runtime-revision.txt 2>/dev/null | tr -d '[:space:]' || true)"
echo "assets/runtime-revision.txt: '${rev}' (${#rev} characters)"
if [ "${#rev}" -ne 16 ]; then
echo "::error::assets/runtime-revision.txt is missing or is not a 16-character digest (got '${rev}'); every device would fall back to RUNTIME_REVISION and keep whatever it unpacked last"
fail=1
fi
exit "$fail"
- uses: actions/upload-artifact@v4
with:
name: pi-android-${{ matrix.variant }}
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
# The licence assets are *generated* from the pinned artifacts, which makes
# them the one app-owned file set that can go stale without anything failing.
# Bump a URL or a version in runtime.lock.json and the APK still builds, still
# installs, and the notices quietly describe the previous release. Nothing on
# a device can notice — a user cannot tell that the git version printed in the
# notices is not the git in the payload — and no reviewer diffs 130 generated
# files by hand. So the invariant is asserted here, where the artifacts are:
# regenerate from the lockfile and require the committed assets to be exactly
# the result. "Remember to re-run the generator" is the same arrangement as the
# hand-written runtime revision this repository already replaced, for the same
# reason: a step a person must remember is a step that eventually is not done.
#
# The coverage check is not redundant with it, because regeneration cannot see
# the bug class that already happened once. For three Ubuntu packages
# `usr/share/doc/<pkg>` is a *symlink* to another package, so a generator that
# only looked for `usr/share/doc/<pkg>/copyright` reported them as shipping no
# licence text when the text was one symlink away — and re-running that
# generator reproduces the same wrong answer. The property is therefore checked
# against the output instead: every package the payload installs has a notice.
- name: Verify the licence assets match the pinned runtime
run: |
set -euo pipefail
python3 tools/build-license-assets.py --fetch-missing
# Modified, added or deleted: all three are drift, and `git diff` alone
# would miss a newly generated file because it is still untracked.
drift="$(git status --porcelain -- app/src/main/assets/licenses)"
if [ -n "$drift" ]; then
echo "$drift"
echo "::error::app/src/main/assets/licenses/** is not what tools/build-license-assets.py produces from the pinned artifacts; a licence notice is describing a different release than the app ships"
exit 1
fi
echo "licence assets are exactly what the pinned artifacts produce"
python3 - <<'PY'
import sys
assets = "app/src/main/assets/licenses"
manifest = set()
for line in open(f"{assets}/manifest.txt", encoding="utf-8"):
line = line.rstrip("\n")
if line and not line.startswith("#"):
manifest.add(line.split("\t")[0])
packages = [
line.split("\t")[0]
for line in open(f"{assets}/ubuntu-packages.txt", encoding="utf-8")
if line.strip()
]
missing = [p for p in packages if f"ubuntu-copyright-{p}.txt" not in manifest]
if missing:
print(
f"::error::{len(missing)} of {len(packages)} packages installed by the "
f"runtime payload have no licence notice: {', '.join(missing)}"
)
sys.exit(1)
print(f"licence coverage: {len(packages)}/{len(packages)} payload packages have their own notice")
PY
# `app/src/test/**` was previously compiled and run by nothing at all: the
# `protocol` job runs `:rpc:test` only, `assembleRelease` does not compile the
# unit-test source set, and `tools/typecheck.sh` compiles `app/src/main/kotlin`
# only. Both harnesses there are bare `main()` functions with a hand-rolled
# `check()` — they import no test framework and call `exitProcess(1)` on failure
# — so they need no Gradle test configuration, which is just as well because
# `app/build.gradle.kts` declares no `testImplementation`. This job compiles each
# harness together with its Android-free main closure using kotlinc fetched from
# Maven Central and runs it; a failed assertion reds the job. No Android SDK, no
# Gradle, no AAPT2: it is a pure-JVM check, which is the point — it can run on a
# machine that cannot build the APK.
pure-checks:
name: App pure-logic checks (bare JVM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
# Holds the ~57 MB Kotlin compiler and the closure jars, keyed on the
# version catalog the script reads its versions from.
- name: Cache the Kotlin compiler and check libraries
uses: actions/cache@v4
with:
path: build/pure-checks
key: pi-pure-checks-deps-${{ hashFiles('gradle/libs.versions.toml') }}
- name: Run app/src/test harnesses
run: |
set -o pipefail
bash tools/run-app-pure-checks.sh 2>&1 | tee /tmp/pure-checks.log
- name: Report harness failures as annotations
if: failure()
run: |
grep -E "error:|FAILED|^FAIL " /tmp/pure-checks.log | tail -40 | while IFS= read -r line; do
echo "::error::$line"
done
# The engine is pinned (`PI_VERSION` in tools/fetch-runtime.mjs) but this app does
# not fork it: it reads pi's files, sends pi's RPC commands and ships extensions
# into pi. Every one of those is a *fact about the pinned engine*, written down in
# App code with a `file:line` that describes one version. Bumping the pin without
# this job keeps the app compiling while the facts move underneath it — which is
# how §M11 (a capability silently downgraded) and §M12 (config keys silently
# deleted) both reached a device. `tools/pi-contract.mjs` asserts the surface and
# the behaviour, and every failure names the App code that has to be re-read.
contract:
name: pi contract (pinned engine)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
# Installing the pinned engine is the bulk of this job's wall time; the key
# names the version so a bump re-installs exactly once.
- name: Cache the pinned engine
uses: actions/cache@v4
with:
path: build/pi-contract
key: pi-contract-${{ hashFiles('tools/fetch-runtime.mjs') }}
- name: Assert the app's assumptions about the pinned engine
run: node tools/pi-contract.mjs
# Kotlin block comments NEST, so a path written as `ui/` followed by two asterisks
# inside a KDoc opens a second comment; the KDoc's closing delimiter closes that one,
# the outer comment stays open, and the compiler swallows the rest of the file. The
# errors it then reports are at the top of the file and describe the symptom
# ("Parameter name expected", "'key' overrides nothing"), with one honest "Unclosed
# comment" at the very last line. That has cost a full CI cycle five times in this
# repository, in five different files, and every time the fix was one character.
# Warnings in task briefs do not work; a check does.
comments:
name: Kotlin comment nesting guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Reject nested block comments
run: python3 tools/check-nested-comments.py