Skip to content

Commit 8fa36fa

Browse files
committed
ci: automate Android verification and releases
1 parent 77f4006 commit 8fa36fa

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

.github/workflows/android-ci.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Android CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: android-ci-${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
verify:
19+
name: Test and build debug APK
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 30
22+
23+
steps:
24+
- name: Check out source
25+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
26+
27+
- name: Set up Java
28+
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
29+
with:
30+
distribution: temurin
31+
java-version: "17"
32+
cache: gradle
33+
34+
- name: Set up Flutter
35+
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
36+
with:
37+
channel: stable
38+
flutter-version: 3.27.4
39+
cache: true
40+
41+
- name: Install dependencies
42+
run: flutter pub get
43+
44+
- name: Run tests
45+
run: flutter test
46+
47+
- name: Run static analysis
48+
run: flutter analyze --no-fatal-infos
49+
50+
- name: Build debug APK
51+
run: flutter build apk --debug
52+
53+
- name: Upload debug APK
54+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
55+
with:
56+
name: marchkov-helper-debug-${{ github.sha }}
57+
path: build/app/outputs/flutter-apk/app-debug.apk
58+
if-no-files-found: error
59+
retention-days: 7
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
name: Android Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*.*.*"
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: Release tag matching pubspec.yaml, for example v2.3.6
11+
required: true
12+
default: v2.3.6
13+
type: string
14+
15+
permissions:
16+
contents: write
17+
18+
concurrency:
19+
group: android-release
20+
cancel-in-progress: false
21+
22+
jobs:
23+
release:
24+
name: Build, verify, and publish APK
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 40
27+
environment: release
28+
29+
steps:
30+
- name: Check out source
31+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Resolve and validate release version
36+
id: release
37+
shell: bash
38+
env:
39+
REQUESTED_TAG: ${{ inputs.tag }}
40+
run: |
41+
set -euo pipefail
42+
43+
tag="$GITHUB_REF_NAME"
44+
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
45+
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
46+
echo "::error::Manual releases must run from the main branch."
47+
exit 1
48+
fi
49+
tag="$REQUESTED_TAG"
50+
fi
51+
52+
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
53+
echo "::error::Release tag must look like v2.3.6."
54+
exit 1
55+
fi
56+
57+
pubspec_version="$(awk '$1 == "version:" { print $2; exit }' pubspec.yaml)"
58+
if [[ "$pubspec_version" != *"+"* ]]; then
59+
echo "::error::pubspec.yaml must contain both versionName and versionCode."
60+
exit 1
61+
fi
62+
63+
version_name="${pubspec_version%%+*}"
64+
version_code="${pubspec_version##*+}"
65+
if [[ "${tag#v}" != "$version_name" ]]; then
66+
echo "::error::Tag $tag does not match pubspec version $version_name."
67+
exit 1
68+
fi
69+
if [[ ! "$version_code" =~ ^[1-9][0-9]*$ ]]; then
70+
echo "::error::Android versionCode must be a positive integer."
71+
exit 1
72+
fi
73+
74+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
75+
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
76+
echo "version_code=$version_code" >> "$GITHUB_OUTPUT"
77+
78+
- name: Set up Java
79+
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
80+
with:
81+
distribution: temurin
82+
java-version: "17"
83+
cache: gradle
84+
85+
- name: Set up Flutter
86+
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
87+
with:
88+
channel: stable
89+
flutter-version: 3.27.4
90+
cache: true
91+
92+
- name: Require release signing secrets
93+
shell: bash
94+
env:
95+
KEYSTORE_BASE64: ${{ secrets.MARCHKOV_KEYSTORE_BASE64 }}
96+
KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
97+
KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
98+
KEY_PASSWORD: ${{ secrets.MARCHKOV_KEY_PASSWORD }}
99+
run: |
100+
set -euo pipefail
101+
missing=0
102+
for variable in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
103+
if [[ -z "${!variable:-}" ]]; then
104+
echo "::error::$variable is not configured in the release environment."
105+
missing=1
106+
fi
107+
done
108+
exit "$missing"
109+
110+
- name: Restore and verify release keystore
111+
shell: bash
112+
env:
113+
KEYSTORE_BASE64: ${{ secrets.MARCHKOV_KEYSTORE_BASE64 }}
114+
KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
115+
KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
116+
run: |
117+
set -euo pipefail
118+
keystore_path="$RUNNER_TEMP/marchkov-release.keystore"
119+
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore_path"
120+
chmod 600 "$keystore_path"
121+
122+
expected="7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6"
123+
keytool_output="$(
124+
LC_ALL=C keytool -list -v \
125+
-keystore "$keystore_path" \
126+
-storepass "$KEYSTORE_PASSWORD" \
127+
-alias "$KEY_ALIAS"
128+
)"
129+
actual="$(
130+
printf '%s\n' "$keytool_output" |
131+
sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' |
132+
head -n 1 |
133+
tr -d ':[:space:]' |
134+
tr '[:lower:]' '[:upper:]'
135+
)"
136+
137+
if [[ "$actual" != "$expected" ]]; then
138+
echo "::error::Keystore certificate $actual does not match the official certificate $expected."
139+
exit 1
140+
fi
141+
echo "Verified official release certificate: $actual"
142+
143+
- name: Install dependencies
144+
run: flutter pub get
145+
146+
- name: Run tests
147+
run: flutter test
148+
149+
- name: Run static analysis
150+
run: flutter analyze --no-fatal-infos
151+
152+
- name: Build signed release APK
153+
env:
154+
MARCHKOV_KEYSTORE_PATH: ${{ runner.temp }}/marchkov-release.keystore
155+
MARCHKOV_KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
156+
MARCHKOV_KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
157+
MARCHKOV_KEY_PASSWORD: ${{ secrets.MARCHKOV_KEY_PASSWORD }}
158+
run: flutter build apk --release
159+
160+
- name: Verify APK signature and prepare assets
161+
id: artifact
162+
shell: bash
163+
env:
164+
RELEASE_TAG: ${{ steps.release.outputs.tag }}
165+
run: |
166+
set -euo pipefail
167+
source_apk="build/app/outputs/flutter-apk/app-release.apk"
168+
sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
169+
apksigner="$(
170+
find "$sdk_root/build-tools" -type f -name apksigner -print |
171+
sort -V |
172+
tail -n 1
173+
)"
174+
if [[ -z "$apksigner" ]]; then
175+
echo "::error::Android apksigner was not found."
176+
exit 1
177+
fi
178+
179+
verify_output="$("$apksigner" verify --verbose --print-certs "$source_apk")"
180+
printf '%s\n' "$verify_output"
181+
expected="7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6"
182+
actual="$(
183+
printf '%s\n' "$verify_output" |
184+
sed -n 's/^Signer #1 certificate SHA-256 digest:[[:space:]]*//p' |
185+
head -n 1 |
186+
tr -d ':[:space:]' |
187+
tr '[:lower:]' '[:upper:]'
188+
)"
189+
if [[ "$actual" != "$expected" ]]; then
190+
echo "::error::Built APK certificate $actual does not match $expected."
191+
exit 1
192+
fi
193+
194+
mkdir -p dist
195+
asset_name="marchkov-helper-$RELEASE_TAG.apk"
196+
cp "$source_apk" "dist/$asset_name"
197+
(
198+
cd dist
199+
sha256sum "$asset_name" > "$asset_name.sha256"
200+
)
201+
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
202+
203+
- name: Upload verified release artifact
204+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
205+
with:
206+
name: marchkov-helper-${{ steps.release.outputs.tag }}-verified
207+
path: dist/
208+
if-no-files-found: error
209+
retention-days: 30
210+
211+
- name: Publish GitHub Release
212+
shell: bash
213+
env:
214+
GH_TOKEN: ${{ github.token }}
215+
RELEASE_TAG: ${{ steps.release.outputs.tag }}
216+
run: |
217+
set -euo pipefail
218+
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
219+
echo "::error::Release $RELEASE_TAG already exists."
220+
exit 1
221+
fi
222+
223+
gh release create "$RELEASE_TAG" \
224+
dist/* \
225+
--repo "$GITHUB_REPOSITORY" \
226+
--target "$GITHUB_SHA" \
227+
--title "$RELEASE_TAG" \
228+
--generate-notes \
229+
--draft
230+
gh release edit "$RELEASE_TAG" \
231+
--repo "$GITHUB_REPOSITORY" \
232+
--draft=false \
233+
--latest
234+
235+
- name: Remove temporary keystore
236+
if: always()
237+
shell: bash
238+
run: rm -f "$RUNNER_TEMP/marchkov-release.keystore"

docs/releasing.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Release 的签名证书,否则 Android 会拒绝覆盖升级。
1111
7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6
1212
```
1313

14+
该指纹是公钥证书的摘要,只能验证签名,不能生成签名。构建正式 APK 仍需要
15+
包含对应私钥的 keystore。证书、APK 或上面的指纹都无法还原私钥。
16+
1417
私钥和密码不得提交到 Git。构建前通过本机环境变量提供:
1518

1619
```text
@@ -22,6 +25,30 @@ MARCHKOV_KEY_PASSWORD
2225

2326
如果缺少任一变量,Gradle 会拒绝生成 Release,避免误用 Debug 证书发布。
2427

28+
## GitHub Actions
29+
30+
`Android CI` 会在 PR 和 `main` 更新时运行测试、静态分析并生成短期 Debug APK,
31+
不接触正式签名材料。
32+
33+
`Android Release` 只在推送 `vX.Y.Z` 标签或手动触发时运行。正式签名材料应配置
34+
在名为 `release` 的 GitHub Environment 中,并建议由仓库所有者设置 required
35+
reviewer。需要以下四个加密 Secret:
36+
37+
```text
38+
MARCHKOV_KEYSTORE_BASE64
39+
MARCHKOV_KEYSTORE_PASSWORD
40+
MARCHKOV_KEY_ALIAS
41+
MARCHKOV_KEY_PASSWORD
42+
```
43+
44+
其中 `MARCHKOV_KEYSTORE_BASE64` 是原 keystore 文件的 Base64 内容,不是证书
45+
指纹。工作流会将其临时解码到 Runner,先检查 keystore 证书,再构建 APK,并对
46+
最终 APK 再检查一次相同指纹。任何 Secret 缺失或指纹不匹配都会阻止 Release。
47+
48+
工作流发布成功后,还需确认 `https://shuttle.variantconst.com/api/version`
49+
`https://shuttle.variantconst.com/api/android_url` 已指向新版本;该站点的当前
50+
源码与部署配置不在本仓库的 `main` 分支中。
51+
2552
## 发布检查
2653

2754
1. 运行 `flutter test``flutter analyze`

0 commit comments

Comments
 (0)