diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index de294b400..56d36132f 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -3,8 +3,12 @@
收到 review 之後請避免 force push,否則審查者看不到你改了什麼。
- 送出之前,請在本機跑過 AGENTS.md「Before pushing」那份清單 —— CI 跑的就是
- 同一份,在本機失敗比在 CI 失敗快得多。
+ 送出之前跑 `tool/commit.sh --push` —— CI 跑的就是同一份,在本機失敗比在 CI
+ 失敗快得多,而且 `.githooks/pre-push` 本來就會替你跑一次。
+
+ **這份描述不會被任何 gate 檢查,也不會進 main。** 被檢查的是分支上每一則
+ commit 訊息,進 main 的也是它們 —— 這個 repo 只開放 rebase 合併。所以要寫給
+ 未來的人看的東西,寫在 commit 訊息裡,不是這裡。
-->
## 這個 PR 做了什麼
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 37c5d16ce..99090fd77 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -43,11 +43,13 @@ jobs:
java-version: "25"
cache: "gradle"
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
- with:
- channel: "stable"
- cache: true
+ # Flutter and Dart come from mise.toml, the same pin CI and every laptop
+ # use. Not subosito/flutter-action with `channel: stable`: that resolves
+ # to whatever stable is on the day, so this job could ship an artifact
+ # built against an SDK nobody chose, and nothing in the build log would
+ # say so. See AGENTS.md → Toolchain.
+ - name: Install toolchain (mise)
+ uses: jdx/mise-action@v4
- name: Cache Gradle
uses: actions/cache@v4
@@ -85,8 +87,8 @@ jobs:
- name: Install dependencies and run build_runner
run: |
- flutter pub get
- dart run build_runner build --delete-conflicting-outputs
+ bash tool/dev/deps.sh
+ bash tool/dev/codegen.sh
- name: Decode keystore
run: |
@@ -102,7 +104,7 @@ jobs:
EOF
- name: Build Release APK
- run: flutter build apk --release
+ run: bash tool/dev/build.sh android
- name: Upload Artifacts
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1438e50c7..a28b3736e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -89,33 +89,6 @@ jobs:
echo "branch gate: rebased on $BASE_REF, no merge commits"
- # The commits on the branch are not what lands on main. This repo
- # squash-merges, so GitHub builds the merged commit out of the PR's title
- # and description — which this gate never saw. That is not hypothetical:
- # `332fb8f3 Fix report (#529)` is on main, passes nothing, and can never
- # be repaired. Check the message that will actually be committed.
- - name: Commit message gate (squashed PR title)
- if: github.event_name == 'pull_request'
- env:
- PR_TITLE: ${{ github.event.pull_request.title }}
- PR_BODY: ${{ github.event.pull_request.body }}
- run: |
- # Via the environment, never through expression interpolation: a PR
- # title is attacker-controlled text, and interpolating it would paste
- # it straight into this shell.
- #
- # This comment may not name the syntax it is warning about. A `run`
- # block is interpolated whole before bash ever sees it, so a literal
- # empty expression here is a *workflow* syntax error — the run is
- # refused before any job is created, which is reported as a failure
- # with no failing step and the file path where the name should be.
- # That is what took CI down; `#` is a shell comment, not a shield.
- {
- printf '%s\n\n' "$PR_TITLE"
- printf '%s\n' "$PR_BODY"
- } > /tmp/pr_message.txt
- bash tool/check/commits.sh --message /tmp/pr_message.txt
-
# The workflows themselves, because a broken one does not fail — it is
# *refused*. GitHub interpolates a `run` block whole before bash sees it,
# so an expression error anywhere in it, comment included, kills the run
diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index 7b7f27f49..56609143e 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -36,32 +36,39 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
- with:
- channel: "stable"
- cache: true
+ # Flutter and Dart come from mise.toml, the same pin CI and every laptop
+ # use. Not subosito/flutter-action with `channel: stable`: that resolves
+ # to whatever stable is on the day, so this job could ship an artifact
+ # built against an SDK nobody chose, and nothing in the build log would
+ # say so. See AGENTS.md → Toolchain.
+ - name: Install toolchain (mise)
+ uses: jdx/mise-action@v4
# The iOS build uses Swift Package Manager, not CocoaPods (there is no
# Podfile). Cache SPM's clone/artifact store keyed on the resolved pins.
- name: Cache Swift Packages
uses: actions/cache@v4
with:
+ # build/ios/SourcePackages, not DerivedData. Flutter always passes
+ # -clonedSourcePackagesDirPath pointing there (ios/xcodeproj.dart), so
+ # DerivedData is never consulted for packages and caching it stored
+ # nothing. Losing this directory is what makes a launch print fifteen
+ # "Fetching from … (cached)" lines and cost 19-32 s.
path: |
~/Library/Caches/org.swift.swiftpm
- ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
+ build/ios/SourcePackages
key: ${{ runner.os }}-spm-${{ hashFiles('ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
- name: Install dependencies and run build_runner
run: |
- flutter pub get
- dart run build_runner build --delete-conflicting-outputs
+ bash tool/dev/deps.sh
+ bash tool/dev/codegen.sh
- name: Build iOS App and create IPA
run: |
- flutter build ios --debug --no-codesign
+ bash tool/dev/build.sh ios --debug
mkdir -p Payload
cp -R build/ios/iphoneos/Runner.app Payload/Runner.app
zip -qr DPIP.ipa Payload
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ff733ef5..82d8ab74f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -331,6 +331,13 @@ jobs:
# for why a release accumulates across the snapshots it followed while a
# snapshot is only a delta.
- name: Release notes
+ env:
+ # Actions does not put GITHUB_TOKEN in the environment; a step has to
+ # ask. Without it notes.sh resolves authors unauthenticated, at 60
+ # requests an hour shared across everything on that runner's IP — and
+ # a rate-limited release produced a note that credited nobody and said
+ # nothing about why.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
bash tool/release/notes.sh \
diff --git a/AGENTS.md b/AGENTS.md
index ad923be2c..85dba337e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -146,7 +146,12 @@ tool/check.sh
That is the whole list, and it is the same list `.github/workflows/ci.yml`
runs — CI calls these scripts rather than naming the commands itself, so the
-two cannot drift. Individually, if you want to fail faster:
+two cannot drift.
+
+CI judges **the commits on the branch**, never the pull request's title or
+description — which is safe because rebase is the only merge this repository
+allows (Settings → Pull requests). What lands on main is what was judged.
+See [commit.md](commit.md). Individually, if you want to fail faster:
```sh
tool/check/commits.sh origin/main..HEAD
diff --git a/README.md b/README.md
index 2fdbd6f82..c51029920 100644
--- a/README.md
+++ b/README.md
@@ -184,10 +184,10 @@ tool/dev/build.sh ios # iOS(不含簽章)
## Star History
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/commit.md b/commit.md
index 2a3bb8b48..5a860becd 100644
--- a/commit.md
+++ b/commit.md
@@ -339,11 +339,13 @@ ci: cache the Swift package resolution
每一項後面標上**真正寫它的人**,以及該則 commit 的連結。
-歸屬不是取 commit 的 author:GitHub squash 一個 PR 時會把作者設成按下合併的人。
-`41a3c1e8 Fix eew (#534)` 的作者是合併者,而它的每一行都是別人寫的。所以摘要帶
-`(#N)` 時,作者取自**那個 PR 自己的 commits**,再併入 `Co-authored-by:` trailer;
-都沒有才退回 commit 的 author。是 GitHub 帳號,不是 git 顯示名稱 —— 顯示名稱 @
-不到任何人。
+歸屬先看 `Co-authored-by:` trailer,沒有就拿 SHA 問 GitHub 這則 commit 的作者
+帳號,再沒有才退回 git 的顯示名稱。要的是 GitHub 帳號,不是顯示名稱 —— 顯示名稱
+@ 不到任何人。
+
+只開放 rebase 合併,所以 main 上的 commit 就是有人寫的那則,作者就是作者。
+**main 上已經用 squash 進來的 282 則不往回相容**:沒有 trailer 的那些會標成按下
+合併的人。這是知情的取捨,不是疏漏 —— 下一份 note 裡的 `c5fdbd31` 就會這樣。
| | 涵蓋範圍 | 為什麼 |
|---|---|---|
@@ -369,9 +371,9 @@ ci: cache the Swift package resolution
實際長相就是上面各節的範例,`tool/release/notes.sh` 直接照這個格式輸出。
-> **squash 會壓縮條目數。** 正則是逐行抓的,所以 squash 不會像舊格式那樣把內容
-> 弄壞——但四則 commit 的條目會全部掛在同一個作者和同一個快照下。要保留就用
-> rebase-merge。
+> **這裡只能 rebase 合併**,squash 和 merge commit 在 repo 設定裡都關掉了。
+> 每則條目的平台圖示、作者、快照標記、commit 連結,四樣都是從那則 commit 的 SHA
+> 推導的 —— squash 之後只剩一個 SHA,四樣就全部指向同一坨。
---
@@ -395,6 +397,20 @@ git push --force-with-lease
pull request 會建一個分支併入 base 的合成 merge,而這個 gate 判的每一則都必須
是有人真的寫過的。
+### gate 只看 commit,不看 PR 標題與描述
+
+**PR 的標題和描述完全不在檢查範圍內。** 被判的是分支上每一則 commit ——
+它們是有人真的寫過的東西,而標題預設是從分支名生出來的(`Fix/version week`),
+描述可以是空的。
+
+這樣是安全的,**前提是合併方式只有 rebase**,而那是 repo 設定裡強制的:
+Settings → Pull requests 只勾了 `Allow rebase merging`。所以進 main 的就是被
+gate 判過的那些 commit 本身,每一則也各自保留自己的條目、作者與快照標記。
+
+把 squash 打開就會破壞這個前提 —— 進 main 的會變成標題加描述,而那兩樣沒有任何
+東西在看。`332fb8f3 Fix report (#529)` 就是這樣進來的:不是任何人寫過的 commit,
+而且改不掉了。
+
---
## 不合格怎麼辦
diff --git a/test/tool/release_notes_wrap_test.dart b/test/tool/release_notes_wrap_test.dart
new file mode 100644
index 000000000..958d6e123
--- /dev/null
+++ b/test/tool/release_notes_wrap_test.dart
@@ -0,0 +1,98 @@
+/// `tool/release/notes.sh` extracts changelog entries with a whole-line regex,
+/// and commit.md's own example wraps an entry across two lines. Those two facts
+/// disagreed, and the disagreement shipped: three English sentences in the
+/// 26w34b notes end mid-clause — "…is shown at full extent on the monitor and
+/// the" — because the indented remainder never matched anything.
+///
+/// Nothing failed. The note built, the release published, and the only symptom
+/// was a sentence that stops.
+library;
+
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+
+/// Runs the fold the extractor applies to a commit body.
+///
+/// Lifted from the script by reading it, so a change to the awk that stops
+/// folding fails here rather than in a published release note.
+String fold(String body) {
+ final script = File('${Directory.current.path}/tool/release/notes.sh')
+ .readAsStringSync();
+ final start = script.indexOf("git log -1 --format=%b \"\$sha\" | awk '");
+ expect(start, isNot(-1), reason: 'the fold is no longer where it was');
+ final awk = script.substring(
+ script.indexOf("'", start) + 1,
+ script.indexOf("')", start),
+ );
+
+ final tmp = File(
+ '${Directory.systemTemp.createTempSync('fold').path}/body.txt',
+ )..writeAsStringSync(body);
+ return Process.runSync('bash', [
+ '-c',
+ "awk ${_q(awk)} ${_q(tmp.path)}",
+ ]).stdout
+ as String;
+}
+
+String _q(String s) => "'${s.replaceAll("'", r"'\''")}'";
+
+/// The regex the script uses, verbatim.
+bool isEntry(String line) => RegExp(
+ r'^(New|Optimization|Fix)\(([A-Za-z]{2,3}(-[A-Za-z0-9]+)*)\):\s*(.+)$',
+).hasMatch(line);
+
+void main() {
+ test('a wrapped entry survives whole', () {
+ const body = '''
+New(en-US): a large event is shown at full extent on the monitor and the
+ replay map
+New(zh-Hant): 大事件在監視器與重播地圖上以完整範圍顯示
+''';
+
+ final entries = fold(body).split('\n').where(isEntry).toList();
+
+ expect(entries, hasLength(2));
+ // The half that used to be dropped.
+ expect(entries.first, endsWith('and the replay map'));
+ });
+
+ test('an unwrapped entry is untouched', () {
+ const body = 'Fix(en-US): stop the crash\nFix(zh-Hant): 修正閃退\n';
+
+ expect(fold(body), body);
+ });
+
+ test('prose after an entry does not get folded into it', () {
+ // The body's explanation is not part of the entry. A blank line ends it,
+ // which is what separates a continuation from the next paragraph.
+ const body = '''
+Fix(en-US): stop the crash
+
+The crash came from a null channel.
+''';
+
+ final entries = fold(body).split('\n').where(isEntry).toList();
+
+ expect(entries, hasLength(1));
+ expect(entries.single, 'Fix(en-US): stop the crash');
+ });
+
+ test('the released commit that exposed this now reads whole', () {
+ // 41a3c1e8 shipped three truncated sentences. If the fold regresses, this
+ // is the commit that will say so.
+ final body =
+ Process.runSync('git', ['log', '-1', '--format=%b', '41a3c1e8']).stdout
+ as String;
+ if (body.trim().isEmpty) return; // shallow clone
+
+ final entries = fold(body).split('\n').where(isEntry).toList();
+
+ expect(
+ entries.where((e) => e.endsWith(' and the')),
+ isEmpty,
+ reason: 'an entry still ends mid-clause',
+ );
+ });
+}
diff --git a/test/tool/run_script_test.dart b/test/tool/run_script_test.dart
index 6025edf80..0be48778d 100644
--- a/test/tool/run_script_test.dart
+++ b/test/tool/run_script_test.dart
@@ -134,6 +134,31 @@ void main() {
}
});
+ test('the toolchain probe does not depend on a shell builtin', () {
+ // `mise exec` runs a binary, not a shell line. macOS ships
+ // /usr/bin/command as a real executable and Linux does not, so
+ // `mise exec -- command -v flutter` answered correctly on a laptop and
+ // answered nothing on every Linux runner — which the guard then reported as
+ // "the SDK is not installed". A probe that is wrong about the toolchain is
+ // worse than no probe.
+ // Code only. The comment above the fix has to be able to name the mistake
+ // it describes, the same way tool/check/tooling.sh lets prose spell out the
+ // command it bans.
+ final code = File('${Directory.current.path}/tool/dev/_lib.sh')
+ .readAsLinesSync()
+ .where((l) => !l.trimLeft().startsWith('#'))
+ .join('\n');
+
+ expect(code, contains('mise which flutter'));
+ for (final builtin in ['command -v', 'type -p', 'hash ']) {
+ expect(
+ code,
+ isNot(contains('mise exec -- $builtin')),
+ reason: '$builtin is a shell builtin; mise execs directly',
+ );
+ }
+ });
+
test('the git hooks point at a script that exists', () {
// The hooks have no file extension, so a rename sweep over `*.sh` misses
// them — and the failure is one line of shell noise on every commit that
diff --git a/tool/check/commits.sh b/tool/check/commits.sh
index e9942b0d7..d7106ebe5 100755
--- a/tool/check/commits.sh
+++ b/tool/check/commits.sh
@@ -14,13 +14,14 @@
#
# With no range it checks HEAD alone, which is what a commit-msg hook wants.
#
-# `--message` exists for the squash-merge case. When a PR is squashed, the
-# commit that lands on `main` is built from the **PR title and description**,
-# not from the branch's commits — so a branch whose every commit passes this
-# gate can still put a malformed message on `main`, and did: `Fix report (#529)`
-# is on `main` now, was never a commit anybody wrote, and cannot be repaired.
-# CI feeds the PR title and body through this mode, so the message that will
-# actually be committed is the one that gets checked.
+# `--message` judges a message that is not a commit yet — a draft, before you
+# spend a `git commit` on it. `tool/commit.sh --message ` is the caller.
+#
+# It used to exist for a different reason: squash merging, where the commit that
+# lands on main is built from the PR title and description and no gate had ever
+# seen it. `332fb8f3 Fix report (#529)` is on main because of that — never a
+# commit anybody wrote, and unrepairable. Squash merging is off now (Settings →
+# Pull requests), so what lands on main is what this gate walked.
set -uo pipefail
mode=range
@@ -67,7 +68,7 @@ check_one() { #