Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Android CI

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: android-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Test and build debug APK
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: temurin
java-version: "17"
cache: gradle

- name: Set up Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: stable
flutter-version: 3.27.4
cache: true

- name: Install dependencies
run: flutter pub get

- name: Run tests
run: flutter test

- name: Run static analysis
run: flutter analyze --no-fatal-infos

- name: Build debug APK
run: flutter build apk --debug

- name: Upload debug APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: marchkov-helper-debug-${{ github.sha }}
path: build/app/outputs/flutter-apk/app-debug.apk
if-no-files-found: error
retention-days: 7
238 changes: 238 additions & 0 deletions .github/workflows/release-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
name: Android Release

on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: Release tag matching pubspec.yaml, for example v2.3.6
required: true
default: v2.3.6
type: string

permissions:
contents: write

concurrency:
group: android-release
cancel-in-progress: false

jobs:
release:
name: Build, verify, and publish APK
runs-on: ubuntu-latest
timeout-minutes: 40
environment: release

steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Resolve and validate release version
id: release
shell: bash
env:
REQUESTED_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail

tag="$GITHUB_REF_NAME"
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "::error::Manual releases must run from the main branch."
exit 1
fi
tag="$REQUESTED_TAG"
fi

if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Release tag must look like v2.3.6."
exit 1
fi

pubspec_version="$(awk '$1 == "version:" { print $2; exit }' pubspec.yaml)"
if [[ "$pubspec_version" != *"+"* ]]; then
echo "::error::pubspec.yaml must contain both versionName and versionCode."
exit 1
fi

version_name="${pubspec_version%%+*}"
version_code="${pubspec_version##*+}"
if [[ "${tag#v}" != "$version_name" ]]; then
echo "::error::Tag $tag does not match pubspec version $version_name."
exit 1
fi
if [[ ! "$version_code" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::Android versionCode must be a positive integer."
exit 1
fi

echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
echo "version_code=$version_code" >> "$GITHUB_OUTPUT"

- name: Set up Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: temurin
java-version: "17"
cache: gradle

- name: Set up Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: stable
flutter-version: 3.27.4
cache: true

- name: Require release signing secrets
shell: bash
env:
KEYSTORE_BASE64: ${{ secrets.MARCHKOV_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.MARCHKOV_KEY_PASSWORD }}
run: |
set -euo pipefail
missing=0
for variable in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
if [[ -z "${!variable:-}" ]]; then
echo "::error::$variable is not configured in the release environment."
missing=1
fi
done
exit "$missing"

- name: Restore and verify release keystore
shell: bash
env:
KEYSTORE_BASE64: ${{ secrets.MARCHKOV_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
run: |
set -euo pipefail
keystore_path="$RUNNER_TEMP/marchkov-release.keystore"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore_path"
chmod 600 "$keystore_path"

expected="7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6"
keytool_output="$(
LC_ALL=C keytool -list -v \
-keystore "$keystore_path" \
-storepass "$KEYSTORE_PASSWORD" \
-alias "$KEY_ALIAS"
)"
actual="$(
printf '%s\n' "$keytool_output" |
sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' |
head -n 1 |
tr -d ':[:space:]' |
tr '[:lower:]' '[:upper:]'
)"

if [[ "$actual" != "$expected" ]]; then
echo "::error::Keystore certificate $actual does not match the official certificate $expected."
exit 1
fi
echo "Verified official release certificate: $actual"

- name: Install dependencies
run: flutter pub get

- name: Run tests
run: flutter test

- name: Run static analysis
run: flutter analyze --no-fatal-infos

- name: Build signed release APK
env:
MARCHKOV_KEYSTORE_PATH: ${{ runner.temp }}/marchkov-release.keystore
MARCHKOV_KEYSTORE_PASSWORD: ${{ secrets.MARCHKOV_KEYSTORE_PASSWORD }}
MARCHKOV_KEY_ALIAS: ${{ secrets.MARCHKOV_KEY_ALIAS }}
MARCHKOV_KEY_PASSWORD: ${{ secrets.MARCHKOV_KEY_PASSWORD }}
run: flutter build apk --release

- name: Verify APK signature and prepare assets
id: artifact
shell: bash
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
source_apk="build/app/outputs/flutter-apk/app-release.apk"
sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
apksigner="$(
find "$sdk_root/build-tools" -type f -name apksigner -print |
sort -V |
tail -n 1
)"
if [[ -z "$apksigner" ]]; then
echo "::error::Android apksigner was not found."
exit 1
fi

verify_output="$("$apksigner" verify --verbose --print-certs "$source_apk")"
printf '%s\n' "$verify_output"
expected="7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6"
actual="$(
printf '%s\n' "$verify_output" |
sed -n 's/^Signer #1 certificate SHA-256 digest:[[:space:]]*//p' |
head -n 1 |
tr -d ':[:space:]' |
tr '[:lower:]' '[:upper:]'
)"
if [[ "$actual" != "$expected" ]]; then
echo "::error::Built APK certificate $actual does not match $expected."
exit 1
fi

mkdir -p dist
asset_name="marchkov-helper-$RELEASE_TAG.apk"
cp "$source_apk" "dist/$asset_name"
(
cd dist
sha256sum "$asset_name" > "$asset_name.sha256"
)
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"

- name: Upload verified release artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: marchkov-helper-${{ steps.release.outputs.tag }}-verified
path: dist/
if-no-files-found: error
retention-days: 30

- name: Publish GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::error::Release $RELEASE_TAG already exists."
exit 1
fi

gh release create "$RELEASE_TAG" \
dist/* \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "$RELEASE_TAG" \
--generate-notes \
--draft
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=false \
--latest

- name: Remove temporary keystore
if: always()
shell: bash
run: rm -f "$RUNNER_TEMP/marchkov-release.keystore"
27 changes: 27 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Release 的签名证书,否则 Android 会拒绝覆盖升级。
7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6
```

该指纹是公钥证书的摘要,只能验证签名,不能生成签名。构建正式 APK 仍需要
包含对应私钥的 keystore。证书、APK 或上面的指纹都无法还原私钥。

私钥和密码不得提交到 Git。构建前通过本机环境变量提供:

```text
Expand All @@ -22,6 +25,30 @@ MARCHKOV_KEY_PASSWORD

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

## GitHub Actions

`Android CI` 会在 PR 和 `main` 更新时运行测试、静态分析并生成短期 Debug APK,
不接触正式签名材料。

`Android Release` 只在推送 `vX.Y.Z` 标签或手动触发时运行。正式签名材料应配置
在名为 `release` 的 GitHub Environment 中,并建议由仓库所有者设置 required
reviewer。需要以下四个加密 Secret:

```text
MARCHKOV_KEYSTORE_BASE64
MARCHKOV_KEYSTORE_PASSWORD
MARCHKOV_KEY_ALIAS
MARCHKOV_KEY_PASSWORD
```

其中 `MARCHKOV_KEYSTORE_BASE64` 是原 keystore 文件的 Base64 内容,不是证书
指纹。工作流会将其临时解码到 Runner,先检查 keystore 证书,再构建 APK,并对
最终 APK 再检查一次相同指纹。任何 Secret 缺失或指纹不匹配都会阻止 Release。

工作流发布成功后,还需确认 `https://shuttle.variantconst.com/api/version` 和
`https://shuttle.variantconst.com/api/android_url` 已指向新版本;该站点的当前
源码与部署配置不在本仓库的 `main` 分支中。

## 发布检查

1. 运行 `flutter test` 和 `flutter analyze`。
Expand Down