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 - - - - - Star History Chart - + + + + + Star History Chart + 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() { #