From 8fa36fa2674a88deacbad4d3ace0ff5b24d4abb6 Mon Sep 17 00:00:00 2001 From: Tim-1e <72056245+Tim-1e@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:42:50 +0800 Subject: [PATCH 1/2] ci: automate Android verification and releases --- .github/workflows/android-ci.yml | 59 +++++++ .github/workflows/release-android.yml | 238 ++++++++++++++++++++++++++ docs/releasing.md | 27 +++ 3 files changed, 324 insertions(+) create mode 100644 .github/workflows/android-ci.yml create mode 100644 .github/workflows/release-android.yml diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..6f7f7a3 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -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@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: marchkov-helper-debug-${{ github.sha }} + path: build/app/outputs/flutter-apk/app-debug.apk + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml new file mode 100644 index 0000000..6b9f912 --- /dev/null +++ b/.github/workflows/release-android.yml @@ -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@11d5960a326750d5838078e36cf38b85af677262 # v4 + 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@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + 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" diff --git a/docs/releasing.md b/docs/releasing.md index 1229eae..f893bcc 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,6 +11,9 @@ Release 的签名证书,否则 Android 会拒绝覆盖升级。 7CE270503976BA420800C44C3E7DC8A8D7B305FBA8C515074ED0EBC9D79F4AA6 ``` +该指纹是公钥证书的摘要,只能验证签名,不能生成签名。构建正式 APK 仍需要 +包含对应私钥的 keystore。证书、APK 或上面的指纹都无法还原私钥。 + 私钥和密码不得提交到 Git。构建前通过本机环境变量提供: ```text @@ -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`。 From 9e3375948468afbf411f87ab66a42e67a2b852ce Mon Sep 17 00:00:00 2001 From: Tim-1e <72056245+Tim-1e@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:52:07 +0800 Subject: [PATCH 2/2] ci: use Node 24 GitHub actions --- .github/workflows/android-ci.yml | 6 +++--- .github/workflows/release-android.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 6f7f7a3..050c87a 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -22,10 +22,10 @@ jobs: steps: - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Java - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: "17" @@ -51,7 +51,7 @@ jobs: run: flutter build apk --debug - name: Upload debug APK - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: marchkov-helper-debug-${{ github.sha }} path: build/app/outputs/flutter-apk/app-debug.apk diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 6b9f912..7493243 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -76,7 +76,7 @@ jobs: echo "version_code=$version_code" >> "$GITHUB_OUTPUT" - name: Set up Java - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: "17" @@ -201,7 +201,7 @@ jobs: echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT" - name: Upload verified release artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: marchkov-helper-${{ steps.release.outputs.tag }}-verified path: dist/