From f995773dd79859184120d0b64ecc71c5e3b7e47a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 09:47:14 +0800 Subject: [PATCH 1/8] ci: judge the branch's commits, not the pull request's title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): CI 不再檢查 PR 標題與描述,只檢查分支上每一則 commit Fix(en-US): CI no longer checks the PR title and body, only the commits on the branch The title defaults to the branch name — `Fix/version week` — and the body can be empty, so the gate was failing pull requests over two fields nobody wrote. The commits are the thing somebody actually wrote, and they were already being judged. What this gives up has to be said plainly: a squash merge commits the title and body, not those commits, so squashing now writes an unread message to main. The only backstop is main's own push event running the gate again, which is detection after the fact. Merge by rebase and what lands is what was judged — and each commit keeps its own changelog entries and its own snapshot marker instead of collapsing them into one. --- .github/pull_request_template.md | 8 ++++++-- .github/workflows/ci.yml | 22 ---------------------- AGENTS.md | 7 ++++++- commit.md | 20 +++++++++++++++++--- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index de294b400..1fa9b6cda 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 的也是它們 —— 前提是用 rebase 合併。用 squash 的話, + 進 main 的會是這裡的標題加描述,而那是沒有人看過的。 --> ## 這個 PR 做了什麼 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1438e50c7..8e1211a7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,28 +94,6 @@ jobs: # 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/AGENTS.md b/AGENTS.md index ad923be2c..5513515bc 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. Merge by rebase, so what lands on main is what was judged; a +squash writes the PR's title and body instead, and nothing has read those. +See [commit.md](commit.md). Individually, if you want to fail faster: ```sh tool/check/commits.sh origin/main..HEAD diff --git a/commit.md b/commit.md index 2a3bb8b48..789208150 100644 --- a/commit.md +++ b/commit.md @@ -369,9 +369,10 @@ ci: cache the Swift package resolution 實際長相就是上面各節的範例,`tool/release/notes.sh` 直接照這個格式輸出。 -> **squash 會壓縮條目數。** 正則是逐行抓的,所以 squash 不會像舊格式那樣把內容 -> 弄壞——但四則 commit 的條目會全部掛在同一個作者和同一個快照下。要保留就用 -> rebase-merge。 +> **不要用 squash 合併。** 兩個理由。第一,正則是逐行抓的,所以 squash 不會像舊 +> 格式那樣把內容弄壞,但四則 commit 的條目會全部掛在同一個作者和同一個快照下 —— +> 正式版更新日誌上「這一項第一次出現在哪個測試版」的標記就沒了。第二,squash 的 +> 訊息來自 PR 的標題與描述,**而那兩樣沒有任何 gate 在看**(見下節)。 --- @@ -395,6 +396,19 @@ git push --force-with-lease pull request 會建一個分支併入 base 的合成 merge,而這個 gate 判的每一則都必須 是有人真的寫過的。 +### gate 只看 commit,不看 PR 標題與描述 + +**PR 的標題和描述完全不在檢查範圍內。** 被判的是分支上每一則 commit —— +它們是有人真的寫過的東西,而標題預設是從分支名生出來的(`Fix/version week`), +描述可以是空的。 + +代價要講清楚:**squash 合併時,進 main 的訊息是標題加描述,不是那些 commit。** +所以 squash 會把一則沒有人檢查過的訊息寫進 main。唯一的後盾是 main 自己的 push +事件會再跑一次 gate —— 那是事後偵測,不是預防,而且發現的時候已經在 main 上了。 + +**所以:用 rebase 合併。** 這樣進 main 的就是那些已經被判過的 commit 本身, +每一則也各自保留自己的更新日誌條目與快照標記。 + --- ## 不合格怎麼辦 From cc2db41ae305879e94e7f785d20fdf4e804d378a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:02:16 +0800 Subject: [PATCH 2/8] docs: point the star-history chart at the new endpoint --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2fdbd6f82..094519a90 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,12 @@ tool/dev/build.sh ios # iOS(不含簽章) ## Star History - - - - - Star History Chart - +## Star History + + + + + + Star History Chart + From 8480cbfc624327ee4be867d7428dd8edc736096f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:06:41 +0800 Subject: [PATCH 3/8] fix(tool): probe the toolchain with mise which, not a shell builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正 Linux 上會誤判「SDK 未安裝」而拒絕執行 Fix(en-US): fix the toolchain guard wrongly refusing to run on Linux `mise exec` runs a binary, not a shell line, and `command` is a shell builtin. macOS happens to ship /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", on a runner where mise had installed it. That is exactly the works-here-fails-there this function exists to stop, so it now asks mise directly, and the message says what it probed rather than asserting a cause it did not establish. --- test/tool/run_script_test.dart | 25 +++++++++++++++++++++++++ tool/dev/_lib.sh | 13 ++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) 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/dev/_lib.sh b/tool/dev/_lib.sh index 3d35b1fcc..7534a20ea 100755 --- a/tool/dev/_lib.sh +++ b/tool/dev/_lib.sh @@ -27,7 +27,7 @@ repo_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; } # or Homebrew SDK reached through a shim, which looks identical in every # log line it will ever print. # -# Checked once per process tree, not per call: `mise exec -- which flutter` +# Checked once per process tree, not per call: `mise which flutter` # costs 0.02 s, which is nothing once and something across a hundred calls. require_mise() { [[ -n ${DPIP_MISE_CHECKED:-} ]] && return 0 @@ -55,12 +55,19 @@ EOF exit 1 fi + # `mise which`, not `mise exec -- command -v`. `command` is a shell builtin, + # and mise execs directly rather than through a shell — macOS happens to ship + # /usr/bin/command as a real executable and Linux does not, so the probe + # passed on a laptop and reported "the SDK is not installed" on every Linux + # runner. Which is precisely the kind of works-here-fails-there this function + # exists to stop. local resolved - resolved="$(cd "$root" && mise exec -- command -v flutter 2>/dev/null || true)" + resolved="$(cd "$root" && mise which flutter 2>/dev/null || true)" if [[ -z $resolved ]]; then cat >&2 <<'EOF' - mise cannot provide flutter. The pin exists but the SDK is not installed: + mise knows the pin but cannot resolve flutter (`mise which flutter` answered + nothing). The SDK is probably not installed: mise install From 29ba8b6fe98f975339429de6e81d911bafcae624 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:17:22 +0800 Subject: [PATCH 4/8] ci(build): put the Android and iOS builds on the pinned toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): Android 與 iOS 的建置改用釘選的 Flutter 版本,不再跟著當日 stable 走 Fix(en-US): the Android and iOS builds now use the pinned Flutter, not whatever stable is today Both jobs used subosito/flutter-action with `channel: stable`, which resolves to whatever stable is on the day. They were the last two places shipping artifacts built against an SDK nobody chose, and nothing in a build log says which SDK produced it — the whole reason the pin exists. They now install mise and call the same tool/ scripts a developer calls. The iOS SPM cache was also pointing at DerivedData, which flutter never uses for packages: it always passes -clonedSourcePackagesDirPath = build/ios/SourcePackages. The cache stored nothing, so every run paid the cold resolve the cache was added to avoid — 19-32 s, measured. `tool/dev/build.sh ios` drops its hardcoded --release, which would have fought the --debug the iOS smoke-test build passes. `flutter build ios` is release by default, so nothing else changes. --- .github/workflows/android.yml | 18 ++++++++++-------- .github/workflows/ios.yml | 25 ++++++++++++++++--------- tool/dev/build.sh | 4 +++- 3 files changed, 29 insertions(+), 18 deletions(-) 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/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/tool/dev/build.sh b/tool/dev/build.sh index 0a5c7fcc9..08c698b2d 100755 --- a/tool/dev/build.sh +++ b/tool/dev/build.sh @@ -22,7 +22,9 @@ shift || true case "$target" in android) pinned flutter build apk --release "$@" ;; bundle) pinned flutter build appbundle --release "$@" ;; - ios) pinned flutter build ios --release --no-codesign "$@" ;; + # No --release: `flutter build ios` is release by default, and hardcoding it + # would fight the `--debug` the iOS smoke-test build passes. + ios) pinned flutter build ios --no-codesign "$@" ;; *) printf 'usage: tool/dev/build.sh {android|bundle|ios} [flutter build args]\n' >&2 exit 2 From bece61d666f7b4429e26ce3dda820cdff310f054 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:17:23 +0800 Subject: [PATCH 5/8] docs: drop the duplicated Star History heading --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 094519a90..c51029920 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,6 @@ tool/dev/build.sh ios # iOS(不含簽章) ## Star History -## Star History - From 29071c89fe0ea80caac39cf8a3098bebc8a64a75 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:30:22 +0800 Subject: [PATCH 6/8] fix(release): stop losing changelog authors silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正更新日誌的署名會在超過 30 則 commit 或被限流時無聲消失 Fix(en-US): release-note attribution no longer disappears silently past 30 commits or under a rate limit Three faults, all of which produced a release note that looked finished and credited nobody. Actions does not put GITHUB_TOKEN in the environment; a step has to ask, and this one never did. So `authors_of` resolved every pull request unauthenticated, at 60 requests an hour shared across everything on that runner's IP. That limit is not theoretical: the API answered 403 while this was being written, with 0 of 60 remaining. The commits endpoint pages at 30 by default and nothing said so, so a pull request with more commits than that lost every author past the thirtieth. The last one had 63. It asks for 100 now, which is the maximum; the endpoint itself stops at 250 whatever is asked for, and past that the co-author trailers are the only record left. And every failure was silent — `2>/dev/null || true` over a body that parses whether it is a commit list or an error object. It now reads the status and says which call failed and why, because a note with no attribution and no complaint is indistinguishable from a release nobody contributed to. --- .github/workflows/release.yml | 7 +++++++ tool/release/notes.sh | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) 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/tool/release/notes.sh b/tool/release/notes.sh index 402dfb71b..19e8267dc 100755 --- a/tool/release/notes.sh +++ b/tool/release/notes.sh @@ -146,11 +146,32 @@ readonly REPO_API='https://api.github.com/repos' # `sed` cannot see structure — it reads the first `"login"` on a line and calls # it the answer, which is right until the day it is not. api_json() { # - curl -sS --max-time 15 \ + local body status + body="$(curl -sS --max-time 15 -w '\n%{http_code}' \ -H 'Accept: application/vnd.github+json' \ ${GITHUB_TOKEN:+-H "Authorization: Bearer $GITHUB_TOKEN"} \ - "$REPO_API/${GITHUB_REPOSITORY:-ExpTechTW/DPIP}/$1" 2>/dev/null | - python3 -c " + "$REPO_API/${GITHUB_REPOSITORY:-ExpTechTW/DPIP}/$1" 2>/dev/null)" + status="${body##*$'\n'}" + body="${body%$'\n'*}" + + # Say so, once per call, rather than returning nothing and letting the note + # ship with no attribution. Unauthenticated the limit is 60 requests an hour + # *per IP*, shared with every other job on that runner, so a rate-limited + # release used to produce a note that looked finished and credited nobody. + case "$status" in + 200) ;; + 403 | 429) + printf 'notes.sh: GitHub rate-limited %s (HTTP %s) — attribution will be incomplete\n' \ + "$1" "$status" >&2 + return 0 + ;; + *) + printf 'notes.sh: GitHub returned HTTP %s for %s\n' "$status" "$1" >&2 + return 0 + ;; + esac + + printf '%s' "$body" | python3 -c " import json,sys try: d = json.load(sys.stdin) except Exception: raise SystemExit @@ -164,7 +185,12 @@ authors_of() { # The pull request's own commits: the only place the real authors survive a # squash intact. if [ -n "$pr" ]; then - logins="$(api_json "pulls/$pr/commits" \ + # `per_page=100`, because the default is 30 and nothing said so: a pull + # request with more commits than that silently lost every author past the + # thirtieth. #533 had 63. 100 is the maximum the API accepts, and the + # endpoint itself stops at 250 commits whatever is asked for — past that the + # tail is unreachable and the trailers below are the only record left. + logins="$(api_json "pulls/$pr/commits?per_page=100" \ "print(' '.join(dict.fromkeys(c['author']['login'] for c in d if c.get('author'))))")" fi From e82b0b7aa8c902f3cd731b5afc64908a4ab77159 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 10:35:57 +0800 Subject: [PATCH 7/8] fix(release): stop cutting wrapped changelog entries mid-sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正換行的更新日誌條目在發布時被從中間截斷 Fix(en-US): a wrapped changelog entry is no longer published cut off mid-sentence The extractor's regex is whole-line and commit.md's own example wraps an entry across two lines, so the indented remainder matched nothing and was dropped in silence. Three English sentences shipped in 26w34b ending mid-clause — "a large event is shown at full extent on the monitor and the" — with "replay map" left behind in the commit nobody reads. Nothing failed. The note built, the release published, and the only symptom was a sentence that stops. So the body is folded before extraction: an indented line continues the one above it, which is what the format already documented and the tool did not implement. A blank line still ends an entry, so the prose an author writes underneath is not swallowed into it. --- test/tool/release_notes_wrap_test.dart | 98 ++++++++++++++++++++++++++ tool/release/notes.sh | 11 ++- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/tool/release_notes_wrap_test.dart 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/tool/release/notes.sh b/tool/release/notes.sh index 19e8267dc..afbd840f5 100755 --- a/tool/release/notes.sh +++ b/tool/release/notes.sh @@ -252,7 +252,16 @@ locales="" for sha in $(git rev-list --no-merges --reverse "$range" 2>/dev/null); do # Read the body once. A commit with no changelog line contributes nothing and # costs no API call. - body="$(git log -1 --format=%b "$sha")" + # An entry may be hard-wrapped — commit.md's own Android example is — and the + # regex below is whole-line, so a continuation used to be dropped in silence. + # Three sentences shipped cut mid-clause in 41a3c1e8: "…on the monitor and + # the", with "replay map" left behind. Folded back on first, so what the + # writer wrote is what the reader gets. + body="$(git log -1 --format=%b "$sha" | awk ' + /^[[:space:]]+[^[:space:]]/ && held { sub(/^[[:space:]]+/, " "); printf "%s", $0; next } + { if (held) printf "\n"; printf "%s", $0; held = 1 } + END { if (held) printf "\n" } + ')" printf '%s\n' "$body" | grep -Eq "$LINE_RE" || continue tag="$(platform_tag "$sha")" From 1e1a0717720c2befd8a27af2388d50fc1ad1a3a7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 18 Aug 2026 15:24:37 +0800 Subject: [PATCH 8/8] refactor(release): drop the squash-era attribution lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only rebase merging is allowed now, so a commit on main is the commit somebody wrote and its author is the author. The path that resolved `(#N)` back to a pull request's own commits existed because a squash sets the author to whoever pressed merge; with squashing off it answers a question nobody asks any more. The cost is taken deliberately, not overlooked: the 282 commits already on main that arrived by squash keep their merge-time author. Trailers still rescue the ones that have them — `41a3c1e8` still credits @PiscesXD — but only 7 of the most recent 40 do, and `c5fdbd31` in the next note will credit the merger for 53 entries somebody else wrote. The comments that still described squashing went with it: the gate's `--message` mode is a draft validator now and says so, and its failure no longer tells anyone to go edit a pull request title. --- .github/pull_request_template.md | 4 ++-- .github/workflows/ci.yml | 5 ----- AGENTS.md | 4 ++-- commit.md | 30 ++++++++++++++++-------------- tool/check/commits.sh | 25 +++++++++++++------------ tool/release/notes.sh | 29 +++++++++++++---------------- 6 files changed, 46 insertions(+), 51 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1fa9b6cda..56d36132f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,8 +7,8 @@ 失敗快得多,而且 `.githooks/pre-push` 本來就會替你跑一次。 **這份描述不會被任何 gate 檢查,也不會進 main。** 被檢查的是分支上每一則 - commit 訊息,進 main 的也是它們 —— 前提是用 rebase 合併。用 squash 的話, - 進 main 的會是這裡的標題加描述,而那是沒有人看過的。 + commit 訊息,進 main 的也是它們 —— 這個 repo 只開放 rebase 合併。所以要寫給 + 未來的人看的東西,寫在 commit 訊息裡,不是這裡。 --> ## 這個 PR 做了什麼 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e1211a7c..a28b3736e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,11 +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. # 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/AGENTS.md b/AGENTS.md index 5513515bc..85dba337e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,8 +149,8 @@ runs — CI calls these scripts rather than naming the commands itself, so the two cannot drift. CI judges **the commits on the branch**, never the pull request's title or -description. Merge by rebase, so what lands on main is what was judged; a -squash writes the PR's title and body instead, and nothing has read those. +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 diff --git a/commit.md b/commit.md index 789208150..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,10 +371,9 @@ ci: cache the Swift package resolution 實際長相就是上面各節的範例,`tool/release/notes.sh` 直接照這個格式輸出。 -> **不要用 squash 合併。** 兩個理由。第一,正則是逐行抓的,所以 squash 不會像舊 -> 格式那樣把內容弄壞,但四則 commit 的條目會全部掛在同一個作者和同一個快照下 —— -> 正式版更新日誌上「這一項第一次出現在哪個測試版」的標記就沒了。第二,squash 的 -> 訊息來自 PR 的標題與描述,**而那兩樣沒有任何 gate 在看**(見下節)。 +> **這裡只能 rebase 合併**,squash 和 merge commit 在 repo 設定裡都關掉了。 +> 每則條目的平台圖示、作者、快照標記、commit 連結,四樣都是從那則 commit 的 SHA +> 推導的 —— squash 之後只剩一個 SHA,四樣就全部指向同一坨。 --- @@ -402,12 +403,13 @@ pull request 會建一個分支併入 base 的合成 merge,而這個 gate 判 它們是有人真的寫過的東西,而標題預設是從分支名生出來的(`Fix/version week`), 描述可以是空的。 -代價要講清楚:**squash 合併時,進 main 的訊息是標題加描述,不是那些 commit。** -所以 squash 會把一則沒有人檢查過的訊息寫進 main。唯一的後盾是 main 自己的 push -事件會再跑一次 gate —— 那是事後偵測,不是預防,而且發現的時候已經在 main 上了。 +這樣是安全的,**前提是合併方式只有 rebase**,而那是 repo 設定裡強制的: +Settings → Pull requests 只勾了 `Allow rebase merging`。所以進 main 的就是被 +gate 判過的那些 commit 本身,每一則也各自保留自己的條目、作者與快照標記。 -**所以:用 rebase 合併。** 這樣進 main 的就是那些已經被判過的 commit 本身, -每一則也各自保留自己的更新日誌條目與快照標記。 +把 squash 打開就會破壞這個前提 —— 進 main 的會變成標題加描述,而那兩樣沒有任何 +東西在看。`332fb8f3 Fix report (#529)` 就是這樣進來的:不是任何人寫過的 commit, +而且改不掉了。 --- 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() { #